From 5e4348ebc18266901d6dedf7915bf746d8bd4211 Mon Sep 17 00:00:00 2001 From: Aodhan Collins Date: Fri, 13 Mar 2026 02:07:16 +0000 Subject: [PATCH] Add extra prompts, endless generation, random character default, and small fixes - Add extra positive/negative prompt textareas to all 9 detail pages with session persistence - Add Endless generation button to all detail pages (continuous preview generation until stopped) - Default character selector to "Random Character" on all secondary detail pages - Fix queue clear endpoint (remove spurious auth check) - Refactor app.py into routes/ and services/ modules - Update CLAUDE.md with new architecture documentation - Various data file updates and cleanup Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 183 +- DEVELOPMENT_GUIDE.md | 51 - TAG_MCP_README.md | 208 - app.py | 7253 +---------------- data/actions/agressivechoking_000010.json | 34 - data/actions/ahegao_xl_v3_1278075.json | 39 - data/actions/before_after_1230829.json | 30 +- data/actions/bodybengirl.json | 12 +- data/actions/butt_smother_ag_000043.json | 4 +- data/actions/buttjob.json | 30 +- .../caught_masturbating_illustrious.json | 40 - data/actions/cheekbulge.json | 6 +- data/actions/cof.json | 14 +- .../disinterested_sex___bored_female.json | 32 +- .../dunking_face_in_a_bowl_of_cum_r1.json | 28 +- data/actions/facial_bukkake.json | 39 - data/actions/giantess_missionary_000037.json | 13 +- .../goblin_molestation_illustrious.json | 32 +- .../goblin_molestation_illustrious_02.json | 34 + data/characters/2b.json | 58 + data/characters/aisha_clan_clan.json | 49 + data/characters/android_21.json | 57 + data/characters/becky_blackbell.json | 61 + data/characters/blossom_ppg.json | 61 + data/characters/bubbles_ppg.json | 57 + data/characters/buttercup_ppg.json | 58 + data/characters/clover_totally_spies.json | 56 + data/characters/hikage_senran_kagura.json | 66 + data/characters/jasmine_disney.json | 4 +- data/characters/lara_croft_classic.json | 4 +- data/characters/princess_bubblegum.json | 66 + data/characters/princess_peach.json | 4 +- data/characters/shiki_senran_kagura.json | 57 + data/characters/starfire_teen_titans.json | 64 + data/characters/yuffie_kisaragi.json | 6 +- .../illustrious_beretmixreal_v80.json | 12 +- .../illustrious_catpony_aniilv51.json | 14 +- .../illustrious_cutecandymix_illustrious.json | 12 +- .../illustrious_kawaiialluxanime.json | 14 +- .../illustrious_perfectdeliberate_v60.json | 14 +- data/clothing/ahsmaidill.json | 2 +- data/clothing/bikini_02.json | 10 +- data/clothing/bitch_illustrious_v1_0.json | 4 +- data/clothing/boundbeltedlatexnurseill.json | 23 +- data/clothing/cafecutiemaidill.json | 4 +- .../cageddemonsunderbustdressill.json | 6 +- data/clothing/candycanelatexlingerieill.json | 33 + data/clothing/checkingitouthaltertopill.json | 4 +- .../extra_microskirt_xl_illustrious_v1_0.json | 4 +- data/clothing/flower_000001_1563226.json | 14 +- data/clothing/french_maid_01.json | 4 +- data/clothing/french_maid_02.json | 4 +- data/clothing/goth_girl_ill.json | 17 +- data/clothing/idolswimsuitil_1665226.json | 28 - data/clothing/laceskimpyleotardill.json | 10 +- data/clothing/latexnurseill.json | 4 +- data/clothing/oilslickdressill.json | 2 +- ...rtff7advchilcasual_illu_dwnsty_000006.json | 32 + ...tff7advchilfeather_illu_dwnsty_000006.json | 31 + ...rtff7amarantsguise_illu_dwnsty_000008.json | 34 + ...hartff7bahamutsuit_illu_dwnsty_000006.json | 31 + ...falockhartff7bunnybustier_illu_dwnsty.json | 33 + data/detailers/cutepussy_000006.json | 5 +- .../sts_age_slider_illustrious_v1.json | 12 +- data/detailers/xtrasmol_000019_1595565.json | 13 + data/looks/aged_up_powerpuff_girls.json | 6 +- data/looks/beardy_man_ilxl_000003.json | 12 +- data/looks/becky_illustrious.json | 4 +- data/looks/bubblegum_ill.json | 4 +- data/looks/cammywhiteillustrious.json | 2 +- data/looks/candycanelatexlingerieill.json | 18 - .../faceless_ugly_bastardv1il_000014.json | 21 +- data/looks/hulkenbergmr_illu_bsinky_v1.json | 6 +- data/looks/starfire_il.json | 8 +- data/looks/tblossom_illustriousxl.json | 4 +- ...rtff7advchilcasual_illu_dwnsty_000006.json | 27 - ...tff7advchilfeather_illu_dwnsty_000006.json | 26 - ...rtff7amarantsguise_illu_dwnsty_000008.json | 30 - ...hartff7bahamutsuit_illu_dwnsty_000006.json | 28 - ...falockhartff7bunnybustier_illu_dwnsty.json | 23 - data/looks/xtrasmol_000019_1595565.json | 19 - data/presets/example_01.json | 10 +- data/prompts/transfer_system.txt | 33 + data/scenes/barbie_b3dr00m_i.json | 4 +- data/scenes/before_the_chalkboard_il.json | 2 +- data/scenes/laboratory___il.json | 4 +- data/scenes/mysterious_4lch3my_sh0p_i.json | 6 +- ...nyillustriousxlfor_v11_came_1420_v1_0.json | 4 +- data/styles/3dvisualart1llust.json | 4 +- .../748cmxl_il_lokr_v6311p_1321893.json | 2 +- data/styles/7b_style.json | 8 +- ...tero__reiq___artist_style_illustrious.json | 2 +- data/styles/anime_artistic_2.json | 4 +- data/styles/blossombreeze1llust.json | 4 +- data/styles/brushwork1llust.json | 20 +- data/styles/cum_on_ero_figur.json | 8 +- data/styles/cunny_000024.json | 20 +- ...tesexyrobutts_style_illustrious_goofy.json | 4 +- data/styles/darkaesthetic2llust.json | 4 +- data/styles/etherealmist1llust.json | 8 +- data/styles/futurism1llust_1549997.json | 4 +- data/styles/inksplash1llust_1448502.json | 2 +- .../zzz_stroll_sticker__style__ilxl.json | 6 +- docker-compose.yml | 17 +- fix_image_modal.py | 55 + fix_quotes.py | 17 + mcp-user-guide.md | 423 - models.py | 145 +- plans/APP_REFACTOR.md | 571 ++ plans/OUTFIT_LOOKS_REFACTOR.md | 1006 +++ plans/TRANSFER.md | 149 + plans/gallery-enhancement-plan.md | 775 ++ routes/__init__.py | 33 + routes/actions.py | 617 ++ routes/characters.py | 873 ++ routes/checkpoints.py | 286 + routes/detailers.py | 457 ++ routes/gallery.py | 332 + routes/generator.py | 139 + routes/looks.py | 592 ++ routes/outfits.py | 604 ++ routes/presets.py | 439 + routes/queue_api.py | 98 + routes/scenes.py | 540 ++ routes/settings.py | 229 + routes/strengths.py | 400 + routes/styles.py | 544 ++ routes/transfer.py | 340 + services/__init__.py | 0 services/comfyui.py | 117 + services/file_io.py | 66 + services/job_queue.py | 265 + services/llm.py | 203 + services/mcp.py | 155 + services/prompts.py | 274 + services/sync.py | 701 ++ services/workflow.py | 342 + .../2029240f6d1128be89ddc32729463129 | Bin 9 -> 9 bytes .../655e209142e59659bd4f552a4a12682c | Bin 0 -> 82 bytes static/js/gallery/gallery-core.js | 1669 ++++ static/style.css | 907 ++- templates/actions/detail.html | 106 +- templates/actions/index.html | 84 +- templates/checkpoints/detail.html | 87 +- templates/checkpoints/index.html | 62 +- templates/detail.html | 189 +- templates/detailers/detail.html | 80 +- templates/detailers/index.html | 85 +- templates/edit.html | 40 +- templates/gallery.html | 444 +- templates/generator.html | 8 +- templates/index.html | 69 +- templates/layout.html | 445 +- templates/looks/detail.html | 150 +- templates/looks/edit.html | 28 +- templates/looks/index.html | 84 +- templates/outfits/detail.html | 126 +- templates/outfits/index.html | 85 +- templates/partials/strengths_gallery.html | 13 +- templates/presets/detail.html | 64 +- templates/scenes/detail.html | 104 +- templates/scenes/index.html | 85 +- templates/styles/detail.html | 93 +- templates/styles/index.html | 85 +- templates/transfer.html | 77 + templates/transfer_resource.html | 101 + test_character_mcp.py | 187 - trigger_bulk.py | 25 - update_templates.py | 54 + utils.py | 67 + 170 files changed, 17367 insertions(+), 9781 deletions(-) delete mode 100644 DEVELOPMENT_GUIDE.md delete mode 100644 TAG_MCP_README.md delete mode 100644 data/actions/agressivechoking_000010.json delete mode 100644 data/actions/ahegao_xl_v3_1278075.json delete mode 100644 data/actions/caught_masturbating_illustrious.json delete mode 100644 data/actions/facial_bukkake.json create mode 100644 data/actions/goblin_molestation_illustrious_02.json create mode 100644 data/characters/2b.json create mode 100644 data/characters/aisha_clan_clan.json create mode 100644 data/characters/android_21.json create mode 100644 data/characters/becky_blackbell.json create mode 100644 data/characters/blossom_ppg.json create mode 100644 data/characters/bubbles_ppg.json create mode 100644 data/characters/buttercup_ppg.json create mode 100644 data/characters/clover_totally_spies.json create mode 100644 data/characters/hikage_senran_kagura.json create mode 100644 data/characters/princess_bubblegum.json create mode 100644 data/characters/shiki_senran_kagura.json create mode 100644 data/characters/starfire_teen_titans.json create mode 100644 data/clothing/candycanelatexlingerieill.json delete mode 100644 data/clothing/idolswimsuitil_1665226.json create mode 100644 data/clothing/tifalockhartff7advchilcasual_illu_dwnsty_000006.json create mode 100644 data/clothing/tifalockhartff7advchilfeather_illu_dwnsty_000006.json create mode 100644 data/clothing/tifalockhartff7amarantsguise_illu_dwnsty_000008.json create mode 100644 data/clothing/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json create mode 100644 data/clothing/tifalockhartff7bunnybustier_illu_dwnsty.json create mode 100644 data/detailers/xtrasmol_000019_1595565.json delete mode 100644 data/looks/candycanelatexlingerieill.json delete mode 100644 data/looks/tifalockhartff7advchilcasual_illu_dwnsty_000006.json delete mode 100644 data/looks/tifalockhartff7advchilfeather_illu_dwnsty_000006.json delete mode 100644 data/looks/tifalockhartff7amarantsguise_illu_dwnsty_000008.json delete mode 100644 data/looks/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json delete mode 100644 data/looks/tifalockhartff7bunnybustier_illu_dwnsty.json delete mode 100644 data/looks/xtrasmol_000019_1595565.json create mode 100644 data/prompts/transfer_system.txt create mode 100644 fix_image_modal.py create mode 100644 fix_quotes.py delete mode 100644 mcp-user-guide.md create mode 100644 plans/APP_REFACTOR.md create mode 100644 plans/OUTFIT_LOOKS_REFACTOR.md create mode 100644 plans/TRANSFER.md create mode 100644 plans/gallery-enhancement-plan.md create mode 100644 routes/__init__.py create mode 100644 routes/actions.py create mode 100644 routes/characters.py create mode 100644 routes/checkpoints.py create mode 100644 routes/detailers.py create mode 100644 routes/gallery.py create mode 100644 routes/generator.py create mode 100644 routes/looks.py create mode 100644 routes/outfits.py create mode 100644 routes/presets.py create mode 100644 routes/queue_api.py create mode 100644 routes/scenes.py create mode 100644 routes/settings.py create mode 100644 routes/strengths.py create mode 100644 routes/styles.py create mode 100644 routes/transfer.py create mode 100644 services/__init__.py create mode 100644 services/comfyui.py create mode 100644 services/file_io.py create mode 100644 services/job_queue.py create mode 100644 services/llm.py create mode 100644 services/mcp.py create mode 100644 services/prompts.py create mode 100644 services/sync.py create mode 100644 services/workflow.py create mode 100644 static/flask_session/655e209142e59659bd4f552a4a12682c create mode 100644 static/js/gallery/gallery-core.js create mode 100644 templates/transfer.html create mode 100644 templates/transfer_resource.html delete mode 100644 test_character_mcp.py delete mode 100644 trigger_bulk.py create mode 100644 update_templates.py create mode 100644 utils.py diff --git a/CLAUDE.md b/CLAUDE.md index e4a25ef..64a30b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,67 @@ The app is deployed locally, connects to a local ComfyUI instance at `http://127 ## Architecture -### Entry Point -- `app.py` — Single-file Flask app (~4500+ lines). All routes, helpers, ComfyUI integration, LLM calls, and sync functions live here. -- `models.py` — SQLAlchemy models only. -- `comfy_workflow.json` — ComfyUI workflow template with placeholder strings. +### File Structure + +``` +app.py # ~186 lines: Flask init, config, logging, route registration, startup/migrations +models.py # SQLAlchemy models only +comfy_workflow.json # ComfyUI workflow template with placeholder strings +utils.py # Pure constants + helpers (no Flask/DB deps) +services/ + __init__.py + comfyui.py # ComfyUI HTTP client (queue_prompt, get_history, get_image) + workflow.py # Workflow building (_prepare_workflow, _apply_checkpoint_settings) + prompts.py # Prompt building + dedup (build_prompt, build_extras_prompt) + llm.py # LLM integration + MCP tool calls (call_llm, load_prompt) + mcp.py # MCP/Docker server lifecycle (ensure_mcp_server_running) + sync.py # All sync_*() functions + preset resolution helpers + job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread) + file_io.py # LoRA/checkpoint scanning, file helpers +routes/ + __init__.py # register_routes(app) — imports and calls all route modules + characters.py # Character CRUD + generation + outfit management + outfits.py # Outfit routes + actions.py # Action routes + styles.py # Style routes + scenes.py # Scene routes + detailers.py # Detailer routes + checkpoints.py # Checkpoint routes + looks.py # Look routes + presets.py # Preset routes + generator.py # Generator mix-and-match page + gallery.py # Gallery browsing + image/resource deletion + settings.py # Settings page + status APIs + context processors + strengths.py # Strengths gallery system + transfer.py # Resource transfer system + queue_api.py # /api/queue/* endpoints +``` + +### Dependency Graph + +``` +app.py + ├── models.py (unchanged) + ├── utils.py (no deps except stdlib) + ├── services/ + │ ├── comfyui.py ← utils (for config) + │ ├── prompts.py ← utils, models + │ ├── workflow.py ← prompts, utils, models + │ ├── llm.py ← mcp (for tool calls) + │ ├── mcp.py ← (stdlib only: subprocess, os) + │ ├── sync.py ← models, utils + │ ├── job_queue.py ← comfyui, models + │ └── file_io.py ← models, utils + └── routes/ + ├── All route modules ← services/*, utils, models + └── (routes never import from other routes) +``` + +**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. + +### Route Registration Pattern + +Routes use a `register_routes(app)` closure pattern — each route module defines a function that receives the Flask `app` object and registers routes via `@app.route()` closures. This preserves all existing `url_for()` endpoint names without requiring Blueprint prefixes. Helper functions used only by routes in that module are defined inside `register_routes()` before the routes that reference them. ### Database SQLite at `instance/database.db`, managed by Flask-SQLAlchemy. The DB is a cache of the JSON files on disk — the JSON files are the source of truth. @@ -65,76 +122,79 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse --- -## Key Helpers +## Key Functions by Module -### `build_prompt(data, selected_fields, default_fields, active_outfit)` -Converts a character (or combined) data dict into `{"main", "face", "hand"}` prompt strings. +### `utils.py` — Constants and Pure Helpers -Field selection priority: -1. `selected_fields` (from form submission) — if non-empty, use it exclusively -2. `default_fields` (saved in DB per character) — used if no form selection -3. Select all (fallback) +- **`_IDENTITY_KEYS` / `_WARDROBE_KEYS`** — Lists of canonical field names for the `identity` and `wardrobe` sections. Used by `_ensure_character_fields()`. +- **`ALLOWED_EXTENSIONS`** — Permitted upload file extensions. +- **`_LORA_DEFAULTS`** — Default LoRA directory paths per category. +- **`parse_orientation(orientation_str)`** — Converts orientation codes (`1F`, `2F`, `1M1F`, etc.) into Danbooru tags. +- **`_resolve_lora_weight(lora_data)`** — Extracts and validates LoRA weight from a lora data dict. +- **`allowed_file(filename)`** — Checks file extension against `ALLOWED_EXTENSIONS`. -Fields are addressed as `"section::key"` strings (e.g. `"identity::hair"`, `"wardrobe::top"`, `"special::name"`, `"lora::lora_triggers"`). +### `services/prompts.py` — Prompt Building -Wardrobe format: Characters support a **nested** format where `wardrobe` is a dict of outfit names → outfit dicts (e.g. `{"default": {...}, "swimsuit": {...}}`). Legacy characters have a flat `wardrobe` dict. `Character.get_active_wardrobe()` handles both. +- **`build_prompt(data, selected_fields, default_fields, active_outfit)`** — Converts a character (or combined) data dict into `{"main", "face", "hand"}` prompt strings. Field selection priority: `selected_fields` → `default_fields` → select all (fallback). Fields are addressed as `"section::key"` strings (e.g. `"identity::hair"`, `"wardrobe::top"`). Characters support a **nested** wardrobe format where `wardrobe` is a dict of outfit names → outfit dicts. +- **`build_extras_prompt(actions, outfits, scenes, styles, detailers)`** — Used by the Generator page. Combines prompt text from all checked items across categories into a single string. +- **`_cross_dedup_prompts(positive, negative)`** — Cross-deduplicates tags between positive and negative prompt strings. Equal counts cancel completely; excess on one side is retained. +- **`_resolve_character(character_slug)`** — Returns a `Character` ORM object for a given slug string. Handles `"__random__"` sentinel. +- **`_ensure_character_fields(character, selected_fields, ...)`** — Mutates `selected_fields` in place, appending populated identity/wardrobe keys. Called in every secondary-category generate route after `_resolve_character()`. +- **`_append_background(prompts, character=None)`** — Appends `" simple background"` tag to `prompts['main']`. -### `build_extras_prompt(actions, outfits, scenes, styles, detailers)` -Used by the Generator page. Combines prompt text from all checked items across categories into a single string. LoRA triggers, wardrobe fields, scene fields, style fields, detailer prompts — all concatenated. +### `services/workflow.py` — Workflow Wiring -### `_prepare_workflow(workflow, character, prompts, checkpoint, custom_negative, outfit, action, style, detailer, scene, width, height, checkpoint_data, look)` -The core workflow wiring function. Mutates the loaded workflow dict in place and returns it. Key behaviours: -- Replaces `{{POSITIVE_PROMPT}}`, `{{FACE_PROMPT}}`, `{{HAND_PROMPT}}` in node inputs. -- If `look` is provided, uses the Look's LoRA in node 16 instead of the character's LoRA, and prepends the Look's negative to node 7. -- Chains LoRA nodes dynamically — only active LoRAs participate in the chain. -- Randomises seeds for nodes 3, 11, 13. -- Applies checkpoint-specific settings (steps, cfg, sampler, VAE, base prompts) via `_apply_checkpoint_settings`. -- Runs `_cross_dedup_prompts` on nodes 6 and 7 as the final step before returning. +- **`_prepare_workflow(workflow, character, prompts, ...)`** — Core workflow wiring function. Replaces prompt placeholders, chains LoRA nodes dynamically, randomises seeds, applies checkpoint settings, runs cross-dedup as the final step. +- **`_apply_checkpoint_settings(workflow, ckpt_data)`** — Applies checkpoint-specific sampler/prompt/VAE settings. +- **`_get_default_checkpoint()`** — Returns `(checkpoint_path, checkpoint_data)` from session, database Settings, or workflow file fallback. +- **`_log_workflow_prompts(label, workflow)`** — Logs the fully assembled workflow prompts in a readable block. -### `_cross_dedup_prompts(positive, negative)` -Cross-deduplicates tags between the positive and negative prompt strings. For each tag present in both, repeatedly removes the first occurrence from each side until the tag exists on only one side (or neither). Equal counts cancel completely; any excess on one side is retained. This allows deliberate overrides: adding a tag twice in positive while it appears once in negative leaves one copy in positive. +### `services/job_queue.py` — Background Job Queue -Called as the last step of `_prepare_workflow`, after `_apply_checkpoint_settings` has added `base_positive`/`base_negative`, so it operates on fully-assembled prompts. +- **`_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. +- **`_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. -### `_IDENTITY_KEYS` / `_WARDROBE_KEYS` (module-level constants) -Lists of canonical field names for the `identity` and `wardrobe` sections. Used by `_ensure_character_fields()` to avoid hard-coding key lists in every route. +### `services/comfyui.py` — ComfyUI HTTP Client -### `_resolve_character(character_slug)` -Returns a `Character` ORM object for a given slug string. Handles the `"__random__"` sentinel by selecting a random character. Returns `None` if `character_slug` is falsy or no match is found. Every route that accepts an optional character dropdown (outfit, action, style, scene, detailer, checkpoint, look generate routes) uses this instead of an inline if/elif block. +- **`queue_prompt(prompt_workflow, client_id)`** — POSTs workflow to ComfyUI's `/prompt` endpoint. +- **`get_history(prompt_id)`** — Polls ComfyUI for job completion. +- **`get_image(filename, subfolder, folder_type)`** — Retrieves generated image bytes. +- **`_ensure_checkpoint_loaded(checkpoint_path)`** — Forces ComfyUI to load a specific checkpoint. -### `_ensure_character_fields(character, selected_fields, include_wardrobe=True, include_defaults=False)` -Mutates `selected_fields` in place, appending any populated identity, wardrobe, and optional defaults keys that are not already present. Ensures `"special::name"` is always included. Called in every secondary-category generate route immediately after `_resolve_character()` to guarantee the character's essential fields are sent to `build_prompt`. +### `services/llm.py` — LLM Integration -### `_append_background(prompts, character=None)` -Appends a `" simple background"` tag (or plain `"simple background"` if no primary color) to `prompts['main']`. Called in outfit, action, style, detailer, and checkpoint generate routes instead of repeating the same inline string construction. +- **`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. +- **`load_prompt(filename)`** — Loads system prompt text from `data/prompts/`. +- **`call_mcp_tool()`** — Synchronous wrapper for MCP tool calls. -### `_make_finalize(category, slug, db_model_class=None, action=None)` -Factory function that returns a `_finalize(comfy_prompt_id, job)` callback closure. The closure: -1. Calls `get_history()` and `get_image()` to retrieve the generated image from ComfyUI. -2. Saves the image to `static/uploads///gen_.png`. -3. Sets `job['result']` with `image_url` and `relative_path`. -4. If `db_model_class` is provided **and** (`action` is `None` or `action == 'replace'`), updates the ORM object's `image_path` and commits. +### `services/sync.py` — Data Synchronization -All `generate` routes pass a `_make_finalize(...)` call as the finalize argument to `_enqueue_job()` instead of defining an inline closure. +- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category. +- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers. -### `_prune_job_history(max_age_seconds=3600)` -Removes entries from `_job_history` that are in a terminal state (`done`, `failed`, `removed`) and older than `max_age_seconds`. Called at the end of every worker loop iteration to prevent unbounded memory growth. +### `services/file_io.py` — File & DB Helpers -### `_queue_generation(character, action, selected_fields, client_id)` -Convenience wrapper for character detail page generation. Loads workflow, calls `build_prompt`, calls `_prepare_workflow`, calls `queue_prompt`. +- **`get_available_loras(category)`** — Scans filesystem for available LoRA files in a category. +- **`get_available_checkpoints()`** — Scans checkpoint directories. +- **`_count_look_assignments()`** / **`_count_outfit_lora_assignments()`** — DB aggregate queries. -### `_queue_detailer_generation(detailer_obj, character, selected_fields, client_id, action, extra_positive, extra_negative)` -Generation helper for the detailer detail page. Merges the detailer's `prompt` list (flattened — `prompt` may be a list or string) and LoRA triggers into the character data before calling `build_prompt`. Appends `extra_positive` to `prompts["main"]` and passes `extra_negative` as `custom_negative` to `_prepare_workflow`. Accepts an optional `action` (Action model object) passed through to `_prepare_workflow` for node 18 LoRA wiring. +### `services/mcp.py` — MCP/Docker Lifecycle -### `_get_default_checkpoint()` -Returns `(checkpoint_path, checkpoint_data)` from the Flask session's `default_checkpoint` key. The global default checkpoint is set via the navbar dropdown and stored server-side in the filesystem session. +- **`ensure_mcp_server_running()`** — Ensures the danbooru-mcp Docker container is running. +- **`ensure_character_mcp_server_running()`** — Ensures the character-mcp Docker container is running. -### `call_llm(prompt, system_prompt)` -OpenAI-compatible chat completion call supporting: -- **OpenRouter** (cloud, configured API key + model) -- **Ollama** / **LMStudio** (local, configured base URL + model) +### Route-local Helpers -Implements a tool-calling loop (up to 10 turns) using `DANBOORU_TOOLS` (search_tags, validate_tags, suggest_tags) via an MCP Docker container (`danbooru-mcp:latest`). If the provider rejects tools (HTTP 400), retries without tools. +Some helpers are defined inside a route module's `register_routes()` since they're only used by routes in that file: +- `routes/scenes.py`: `_queue_scene_generation()` — scene-specific workflow builder +- `routes/detailers.py`: `_queue_detailer_generation()` — detailer-specific generation helper +- `routes/styles.py`: `_build_style_workflow()` — style-specific workflow builder +- `routes/checkpoints.py`: `_build_checkpoint_workflow()` — checkpoint-specific workflow builder +- `routes/strengths.py`: `_build_strengths_prompts()`, `_prepare_strengths_workflow()` — strengths gallery helpers +- `routes/transfer.py`: `_create_minimal_template()` — transfer template builder +- `routes/gallery.py`: `_scan_gallery_images()`, `_enrich_with_names()`, `_parse_comfy_png_metadata()` --- @@ -377,13 +437,14 @@ Absolute paths on disk: To add a new content category (e.g. "Poses" as a separate concept from Actions), the pattern is: 1. **Model** (`models.py`): Add a new SQLAlchemy model with the standard fields. -2. **Sync function** (`app.py`): Add `sync_newcategory()` following the pattern of `sync_outfits()`. -3. **Data directory**: Add `app.config['NEWCATEGORY_DIR'] = 'data/newcategory'`. -4. **Routes**: Implement index, detail, edit, generate, finalize_generation, replace_cover_from_preview, upload, save_defaults, clone, rescan routes. Follow the outfit/scene pattern exactly. -5. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. -6. **Nav**: Add link to navbar in `templates/layout.html`. -7. **`with_appcontext`**: Call `sync_newcategory()` in the `with app.app_context()` block at the bottom of `app.py`. -8. **Generator page**: Add to `generator()` route, `build_extras_prompt()`, and `templates/generator.html` accordion. +2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` following the pattern of `sync_outfits()`. +3. **Data directory** (`app.py`): Add `app.config['NEWCATEGORY_DIR'] = 'data/newcategory'`. +4. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Implement index, detail, edit, generate, replace_cover_from_preview, upload, save_defaults, clone, rescan routes. Follow `routes/outfits.py` or `routes/scenes.py` exactly. +5. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`. +6. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. +7. **Nav**: Add link to navbar in `templates/layout.html`. +8. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block. +9. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion. --- diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md deleted file mode 100644 index ac7fcbb..0000000 --- a/DEVELOPMENT_GUIDE.md +++ /dev/null @@ -1,51 +0,0 @@ -# Feature Development Guide: Gallery Pages & Character Integration - -This guide outlines the architectural patterns and best practices developed during the implementation of the **Actions**, **Outfits**, and **Styles** galleries. Use this as a blueprint for adding similar features (e.g., "Scenes", "Props", "Effects"). - -## 1. Data Model & Persistence -- **Database Model:** Add a new class in `models.py`. Include `default_fields` (JSON) to support persistent prompt selections. -- **JSON Sync:** Implement a `sync_[feature]()` function in `app.py` to keep the SQLite database in sync with the `data/[feature]/*.json` files. -- **Slugs:** Use URL-safe slugs generated from the ID for clean routing. - -## 2. Quadruple LoRA Chaining -Our workflow supports chaining up to four distinct LoRAs from specific directories: -1. **Character:** `Illustrious/Looks/` (Node 16) -2. **Outfit:** `Illustrious/Clothing/` (Node 17) -3. **Action:** `Illustrious/Poses/` (Node 18) -4. **Style:** `Illustrious/Styles/` (Node 19) - -**Implementation Detail:** -In `_prepare_workflow`, LoRAs must be chained sequentially. If a previous LoRA is missing, the next one must "reach back" to the Checkpoint (Node 4) or the last valid node in the chain to maintain the model/CLIP connection. Node 19 is the terminal LoRA loader before terminal consumers (KSampler, Detailers). - -## 3. Style Prompt Construction -Artistic styles follow specific formatting rules in the `build_prompt` engine: -- **Artist Attribution:** Artist names are prefixed with "by " (e.g., "by Sabu"). -- **Artistic Styles:** Raw descriptive style tags (e.g., "watercolor painting") are appended to the prompt. -- **Priority:** Style tags are applied after identity and wardrobe tags but before trigger words. - -## 4. Adetailer Routing -To improve generation quality, route specific JSON sub-fields to targeted Adetailers: -- **Face Detailer (Node 14):** Receives `character_name`, `expression`, and action-specific `head`/`eyes` tags. -- **Hand Detailer (Node 15):** Receives priority hand tags (Wardrobe Gloves > Wardrobe Hands > Identity Hands) and action-specific `arms`/`hands` tags. - -## 5. Character-Integrated Previews -The "Killer Feature" is previewing a standalone item (like an Action, Outfit, or Style) on a specific character. - -**Logic Flow:** -1. **Merge Data:** Copy `character.data`. -2. **Override/Merge:** Replace character `defaults` with feature-specific tags (e.g., Action pose overrides Character pose). -3. **Context Injection:** Append character-specific styles (e.g., `[primary_color] simple background`) to the main prompt. -4. **Auto-Selection:** When a character is selected, ensure their essential identity and active wardrobe fields are automatically included in the prompt. - -## 6. UI/UX Patterns -- **Selection Boxes:** Use checkboxes next to field labels to allow users to toggle specific tags. -- **Default Selection:** Implement a "Save as Default Selection" button that persists the current checkbox state to the database. -- **Session State:** Store the last selected character and field preferences in the Flask `session` to provide a seamless experience. -- **AJAX Generation:** Use the WebSocket + Polling hybrid pattern in the frontend to show real-time progress bars without page reloads. - -## 7. Directory Isolation -Always isolate LoRAs by purpose to prevent dropdown clutter: -- `get_available_loras()` -> Characters -- `get_available_clothing_loras()` -> Outfits -- `get_available_action_loras()` -> Actions/Poses -- `get_available_style_loras()` -> Styles diff --git a/TAG_MCP_README.md b/TAG_MCP_README.md deleted file mode 100644 index b34fa78..0000000 --- a/TAG_MCP_README.md +++ /dev/null @@ -1,208 +0,0 @@ -# danbooru-mcp - -An MCP (Model Context Protocol) server that lets an LLM search, validate, and get suggestions for valid **Danbooru tags** — the prompt vocabulary used by Illustrious and other Danbooru-trained Stable Diffusion models. - -Tags are scraped directly from the **Danbooru public API** and stored in a local SQLite database with an **FTS5 full-text search index** for fast prefix/substring queries. Each tag includes its post count, category, and deprecation status so the LLM can prioritise well-used, canonical tags. - ---- - -## Tools - -| Tool | Description | -|------|-------------| -| `search_tags(query, limit=20, category=None)` | Prefix/full-text search — returns rich tag objects ordered by relevance | -| `validate_tags(tags)` | Exact-match validation — splits into `valid`, `deprecated`, `invalid` | -| `suggest_tags(partial, limit=10, category=None)` | Autocomplete for partial tag strings, sorted by post count | - -### Return object shape - -All tools return tag objects with: - -```json -{ - "name": "blue_hair", - "post_count": 1079908, - "category": "general", - "is_deprecated": false -} -``` - -### Category filter values - -`"general"` · `"artist"` · `"copyright"` · `"character"` · `"meta"` - ---- - -## Setup - -### 1. Install dependencies - -```bash -pip install -e . -``` - -### 2. Build the SQLite database (scrapes the Danbooru API) - -```bash -python scripts/scrape_tags.py -``` - -This scrapes ~1–2 million tags from the Danbooru public API (no account required) -and stores them in `db/tags.db` with a FTS5 index. -Estimated time: **5–15 minutes** depending on network speed. - -``` -Options: - --db PATH Output database path (default: db/tags.db) - --workers N Parallel HTTP workers (default: 4) - --max-page N Safety cap on pages (default: 2500) - --no-resume Re-scrape all pages from scratch - --no-fts Skip FTS5 rebuild (for incremental runs) -``` - -The scraper is **resumable** — if interrupted, re-run it and it will -continue from where it left off. - -### 3. (Optional) Test API access first - -```bash -python scripts/test_danbooru_api.py -``` - -### 4. Run the MCP server - -```bash -python src/server.py -``` - ---- - -## Docker - -### Quick start (pre-built DB — recommended) - -Use this when you've already run `python scripts/scrape_tags.py` and have `db/tags.db`: - -```bash -# Build image with the pre-built DB baked in (~30 seconds) -docker build -f Dockerfile.prebuilt -t danbooru-mcp . - -# Verify -docker run --rm --entrypoint python danbooru-mcp \ - -c "import sqlite3,sys; c=sqlite3.connect('/app/db/tags.db'); sys.stderr.write(str(c.execute('SELECT COUNT(*) FROM tags').fetchone()[0]) + ' tags\n')" -``` - -### Build from scratch (runs the scraper during Docker build) - -```bash -# Scrapes the Danbooru API during build — takes ~15 minutes -docker build \ - --build-arg DANBOORU_USER=your_username \ - --build-arg DANBOORU_API_KEY=your_api_key \ - -t danbooru-mcp . -``` - -### MCP client config (Docker) - -```json -{ - "mcpServers": { - "danbooru-tags": { - "command": "docker", - "args": ["run", "--rm", "-i", "danbooru-mcp:latest"] - } - } -} -``` - ---- - -## MCP Client Configuration - -### Claude Desktop (`claude_desktop_config.json`) - -```json -{ - "mcpServers": { - "danbooru-tags": { - "command": "python", - "args": ["/absolute/path/to/danbooru-mcp/src/server.py"] - } - } -} -``` - -### Custom DB path via environment variable - -```json -{ - "mcpServers": { - "danbooru-tags": { - "command": "python", - "args": ["/path/to/src/server.py"], - "env": { - "DANBOORU_TAGS_DB": "/custom/path/to/tags.db" - } - } - } -} -``` - ---- - -## Example LLM Prompt Workflow - -``` -User: Generate a prompt for a girl with blue hair and a sword. - -LLM calls validate_tags(["1girl", "blue_hairs", "sword", "looking_at_vewer"]) -→ { - "valid": ["1girl", "sword"], - "deprecated": [], - "invalid": ["blue_hairs", "looking_at_vewer"] - } - -LLM calls suggest_tags("blue_hair", limit=3) -→ [ - {"name": "blue_hair", "post_count": 1079908, "category": "general"}, - {"name": "blue_hairband", "post_count": 26905, "category": "general"}, - ... - ] - -LLM calls suggest_tags("looking_at_viewer", limit=1) -→ [{"name": "looking_at_viewer", "post_count": 4567890, "category": "general"}] - -Final validated prompt: 1girl, blue_hair, sword, looking_at_viewer -``` - ---- - -## Project Structure - -``` -danbooru-mcp/ -├── data/ -│ └── all_tags.csv # original CSV export (legacy, replaced by API scrape) -├── db/ -│ └── tags.db # SQLite DB (generated, gitignored) -├── plans/ -│ └── danbooru-mcp-plan.md # Architecture plan -├── scripts/ -│ ├── scrape_tags.py # API scraper → SQLite (primary) -│ ├── import_tags.py # Legacy CSV importer -│ └── test_danbooru_api.py # API connectivity tests -├── src/ -│ └── server.py # MCP server -├── pyproject.toml -├── .gitignore -└── README.md -``` - ---- - -## Requirements - -- Python 3.10+ -- `mcp[cli]` — official Python MCP SDK -- `requests` — HTTP client for API scraping -- `sqlite3` — Python stdlib (no install needed) diff --git a/app.py b/app.py index 1ab1c8d..2cf131b 100644 --- a/app.py +++ b/app.py @@ -1,27 +1,14 @@ import os -import json import logging -import time -import re -import requests -import random -import asyncio -import subprocess -import threading -import uuid -from collections import deque -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from flask import Flask, render_template, request, redirect, url_for, flash, session +from flask import Flask from flask_session import Session -from werkzeug.utils import secure_filename -from models import db, Character, Settings, Outfit, Action, Style, Detailer, Scene, Checkpoint, Look, Preset +from models import db, Settings, Look app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['UPLOAD_FOLDER'] = 'static/uploads' -app.config['SECRET_KEY'] = 'dev-key-123' +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-123') app.config['CHARACTERS_DIR'] = 'data/characters' app.config['CLOTHING_DIR'] = 'data/clothing' app.config['ACTIONS_DIR'] = 'data/actions' @@ -59,7197 +46,29 @@ logger = logging.getLogger('gaze') logger.setLevel(log_level) # --------------------------------------------------------------------------- -# Generation Job Queue +# Register all routes # --------------------------------------------------------------------------- -# Each job is a dict: -# id — unique UUID string -# label — human-readable description (e.g. "Tifa Lockhart – preview") -# status — 'pending' | 'processing' | 'done' | 'failed' | 'paused' | 'removed' -# workflow — the fully-prepared ComfyUI workflow dict -# finalize_fn — callable(comfy_prompt_id, job) that saves the image; called after ComfyUI finishes -# error — error message string (when status == 'failed') -# result — dict with image_url etc. (set by finalize_fn on success) -# created_at — unix timestamp -# comfy_prompt_id — the prompt_id returned by ComfyUI (set when processing starts) - -_job_queue_lock = threading.Lock() -_job_queue = deque() # ordered list of job 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 - - -def _enqueue_job(label, workflow, finalize_fn): - """Add a generation job to the queue. Returns the job dict.""" - job = { - 'id': str(uuid.uuid4()), - 'label': label, - 'status': 'pending', - 'workflow': workflow, - 'finalize_fn': finalize_fn, - 'error': None, - 'result': None, - 'created_at': time.time(), - 'comfy_prompt_id': None, - } - with _job_queue_lock: - _job_queue.append(job) - _job_history[job['id']] = job - logger.info("Job queued: [%s] %s", job['id'][:8], label) - _queue_worker_event.set() - return job - - -def _queue_worker(): - """Background thread: processes jobs from _job_queue sequentially.""" - while True: - _queue_worker_event.wait() - _queue_worker_event.clear() - - while True: - job = None - with _job_queue_lock: - # Find the first pending job - for j in _job_queue: - if j['status'] == 'pending': - job = j - break - - if job is None: - break # No pending jobs — go back to waiting - - # Mark as processing - with _job_queue_lock: - job['status'] = 'processing' - - logger.info("=" * 80) - logger.info("JOB STARTED: [%s] %s", job['id'][:8], job['label']) - logger.info("Job created at: %s", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(job['created_at']))) - - # Log workflow summary before sending to ComfyUI - workflow = job['workflow'] - logger.info("Workflow summary:") - logger.info(" Checkpoint: %s", workflow.get('4', {}).get('inputs', {}).get('ckpt_name', '(not set)')) - logger.info(" Seed: %s", workflow.get('3', {}).get('inputs', {}).get('seed', '(not set)')) - logger.info(" Resolution: %sx%s", - workflow.get('5', {}).get('inputs', {}).get('width', '?'), - workflow.get('5', {}).get('inputs', {}).get('height', '?')) - logger.info(" Sampler: %s / %s (steps=%s, cfg=%s)", - workflow.get('3', {}).get('inputs', {}).get('sampler_name', '?'), - workflow.get('3', {}).get('inputs', {}).get('scheduler', '?'), - workflow.get('3', {}).get('inputs', {}).get('steps', '?'), - workflow.get('3', {}).get('inputs', {}).get('cfg', '?')) - - # Log active LoRAs - active_loras = [] - for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: - if node_id in workflow: - lora_name = workflow[node_id]["inputs"].get("lora_name", "") - if lora_name: - strength = workflow[node_id]["inputs"].get("strength_model", "?") - active_loras.append(f"{label_str}:{lora_name.split('/')[-1]}@{strength}") - logger.info(" Active LoRAs: %s", ' | '.join(active_loras) if active_loras else '(none)') - - # Log prompts - logger.info(" Positive prompt: %s", workflow.get('6', {}).get('inputs', {}).get('text', '(not set)')[:200]) - logger.info(" Negative prompt: %s", workflow.get('7', {}).get('inputs', {}).get('text', '(not set)')[:200]) - logger.info("=" * 80) - - try: - with app.app_context(): - # Send workflow to ComfyUI - logger.info("Sending workflow to ComfyUI...") - prompt_response = queue_prompt(job['workflow']) - if 'prompt_id' not in prompt_response: - raise Exception(f"ComfyUI rejected job: {prompt_response.get('error', 'unknown error')}") - - comfy_id = prompt_response['prompt_id'] - with _job_queue_lock: - job['comfy_prompt_id'] = comfy_id - logger.info("Job [%s] queued in ComfyUI as %s", job['id'][:8], comfy_id) - - # Poll until done (max ~10 minutes) - max_retries = 300 - finished = False - poll_count = 0 - logger.info("Polling ComfyUI for completion (max %d retries, 2s interval)...", max_retries) - while max_retries > 0: - history = get_history(comfy_id) - if comfy_id in history: - finished = True - logger.info("Generation completed after %d polls (%d seconds)", - poll_count, poll_count * 2) - break - poll_count += 1 - if poll_count % 10 == 0: # Log every 20 seconds - logger.info("Still waiting for generation... (%d polls, %d seconds elapsed)", - poll_count, poll_count * 2) - time.sleep(2) - max_retries -= 1 - - if not finished: - raise Exception("ComfyUI generation timed out") - - logger.info("Job [%s] generation complete, finalizing...", job['id'][:8]) - # Run the finalize callback (saves image to disk / DB) - # finalize_fn(comfy_prompt_id, job) — job is passed so callback can store result - job['finalize_fn'](comfy_id, job) - - with _job_queue_lock: - job['status'] = 'done' - logger.info("=" * 80) - logger.info("JOB COMPLETED: [%s] %s", job['id'][:8], job['label']) - logger.info("=" * 80) - - except Exception as e: - logger.error("=" * 80) - logger.exception("JOB FAILED: [%s] %s — %s", job['id'][:8], job['label'], e) - logger.error("=" * 80) - with _job_queue_lock: - job['status'] = 'failed' - job['error'] = str(e) - - # Remove completed/failed jobs from the active queue (keep in history) - with _job_queue_lock: - try: - _job_queue.remove(job) - except ValueError: - pass # Already removed (e.g. by user) - - # Periodically purge old finished jobs from history to avoid unbounded growth - _prune_job_history() - - -# Start the background worker thread -_worker_thread = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker') -_worker_thread.start() - - -def _make_finalize(category, slug, db_model_class=None, action=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 - """ - def _finalize(comfy_prompt_id, job): - logger.debug("=" * 80) - logger.debug("FINALIZE - Starting finalization for prompt ID: %s", comfy_prompt_id) - logger.debug("Category: %s, Slug: %s, Action: %s", category, slug, action) - - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - - logger.debug("Processing outputs from %d node(s)", len(outputs)) - for node_id, node_output in outputs.items(): - logger.debug(" Node %s: %s", node_id, list(node_output.keys())) - if 'images' in node_output: - logger.debug(" Found %d image(s) in node %s", len(node_output['images']), node_id) - image_info = node_output['images'][0] - logger.debug(" Image info: filename=%s, subfolder=%s, type=%s", - image_info['filename'], image_info['subfolder'], image_info['type']) - - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - folder = os.path.join(app.config['UPLOAD_FOLDER'], f"{category}/{slug}") - os.makedirs(folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - full_path = os.path.join(folder, filename) - - logger.debug(" Saving image to: %s", full_path) - with open(full_path, 'wb') as f: - f.write(image_data) - logger.info("Image saved: %s (%d bytes)", full_path, len(image_data)) - - relative_path = f"{category}/{slug}/{filename}" - job['result'] = { - 'image_url': f'/static/uploads/{relative_path}', - 'relative_path': relative_path, - } - - if db_model_class and (action is None or action == 'replace'): - logger.debug(" Updating database: %s.image_path = %s", db_model_class.__name__, relative_path) - obj = db_model_class.query.filter_by(slug=slug).first() - if obj: - obj.image_path = relative_path - db.session.commit() - logger.debug(" Database updated successfully") - else: - logger.warning(" Object not found in database: %s(slug=%s)", db_model_class.__name__, slug) - else: - logger.debug(" Skipping database update (db_model_class=%s, action=%s)", - db_model_class.__name__ if db_model_class else None, action) - - logger.debug("FINALIZE - Completed successfully") - logger.debug("=" * 80) - return - - logger.warning("FINALIZE - No images found in outputs!") - logger.debug("=" * 80) - return _finalize - - -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 - with _job_queue_lock: - stale = [jid for jid, j in _job_history.items() - if j['status'] in ('done', 'failed', 'removed') and j['created_at'] < cutoff] - for jid in stale: - del _job_history[jid] - +from routes import register_routes +register_routes(app) # --------------------------------------------------------------------------- -# Queue API routes +# Startup # --------------------------------------------------------------------------- - -@app.route('/api/queue') -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 - ] - 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')) - return {'count': count} - - -@app.route('/api/queue//remove', methods=['POST']) -def api_queue_remove(job_id): - """Remove a pending or paused job from the queue.""" - with _job_queue_lock: - job = _job_history.get(job_id) - if not job: - 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 - job['status'] = 'removed' - return {'status': 'ok'} - - -@app.route('/api/queue//pause', methods=['POST']) -def api_queue_pause(job_id): - """Toggle pause/resume on a pending job.""" - with _job_queue_lock: - job = _job_history.get(job_id) - if not job: - return {'error': 'Job not found'}, 404 - if job['status'] == 'pending': - job['status'] = 'paused' - elif job['status'] == 'paused': - job['status'] = 'pending' - _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//status') -def api_queue_job_status(job_id): - """Return the status of a specific job.""" - with _job_queue_lock: - job = _job_history.get(job_id) - if not job: - return {'error': 'Job not found'}, 404 - resp = { - 'id': job['id'], - 'label': job['label'], - 'status': job['status'], - 'error': job['error'], - 'comfy_prompt_id': job['comfy_prompt_id'], - } - if job.get('result'): - resp['result'] = job['result'] - return resp - - -# Path to the danbooru-mcp docker-compose project, relative to this file. -MCP_TOOLS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tools') -MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp') -MCP_REPO_URL = 'https://git.liveaodh.com/aodhan/danbooru-mcp' - - -def _ensure_mcp_repo(): - """Clone or update the danbooru-mcp source repository inside tools/. - - - If ``tools/danbooru-mcp/`` does not exist, clone from MCP_REPO_URL. - - If it already exists, run ``git pull`` to fetch the latest changes. - Errors are non-fatal. - """ - os.makedirs(MCP_TOOLS_DIR, exist_ok=True) - try: - if not os.path.isdir(MCP_COMPOSE_DIR): - print(f'Cloning danbooru-mcp from {MCP_REPO_URL} …') - subprocess.run( - ['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR], - timeout=120, check=True, - ) - print('danbooru-mcp cloned successfully.') - else: - print('Updating danbooru-mcp via git pull …') - subprocess.run( - ['git', 'pull'], - cwd=MCP_COMPOSE_DIR, - timeout=60, check=True, - ) - print('danbooru-mcp updated.') - except FileNotFoundError: - print('WARNING: git not found on PATH — danbooru-mcp repo will not be cloned/updated.') - except subprocess.CalledProcessError as e: - print(f'WARNING: git operation failed for danbooru-mcp: {e}') - except subprocess.TimeoutExpired: - print('WARNING: git timed out while cloning/updating danbooru-mcp.') - except Exception as e: - print(f'WARNING: Could not clone/update danbooru-mcp repo: {e}') - - -def ensure_mcp_server_running(): - """Ensure the danbooru-mcp repo is present/up-to-date, then start the - Docker container if it is not already running. - - Uses ``docker compose up -d`` so the image is built automatically on first - run. Errors are non-fatal — the app will still start even if Docker is - unavailable. - - Skipped when ``SKIP_MCP_AUTOSTART=true`` (set by docker-compose, where the - danbooru-mcp service is managed by compose instead). - """ - if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true': - print('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.') - return - _ensure_mcp_repo() - try: - result = subprocess.run( - ['docker', 'ps', '--filter', 'name=danbooru-mcp', '--format', '{{.Names}}'], - capture_output=True, text=True, timeout=10, - ) - if 'danbooru-mcp' in result.stdout: - print('danbooru-mcp container already running.') - return - # Container not running — start it via docker compose - print('Starting danbooru-mcp container via docker compose …') - subprocess.run( - ['docker', 'compose', 'up', '-d'], - cwd=MCP_COMPOSE_DIR, - timeout=120, - ) - print('danbooru-mcp container started.') - except FileNotFoundError: - print('WARNING: docker not found on PATH — danbooru-mcp will not be started automatically.') - except subprocess.TimeoutExpired: - print('WARNING: docker timed out while starting danbooru-mcp.') - except Exception as e: - print(f'WARNING: Could not ensure danbooru-mcp is running: {e}') - - -@app.context_processor -def inject_comfyui_ws_url(): - url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') - # If the URL is localhost/127.0.0.1, replace it with the current request's host - # so that remote clients connect to the correct machine for WebSockets. - if '127.0.0.1' in url or 'localhost' in url: - host = request.host.split(':')[0] - url = url.replace('127.0.0.1', host).replace('localhost', host) - - # Convert http/https to ws/wss - ws_url = url.replace('http://', 'ws://').replace('https://', 'wss://') - return dict(COMFYUI_WS_URL=f"{ws_url}/ws") - -@app.context_processor -def inject_default_checkpoint(): - from models import Checkpoint - checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() - return dict(all_checkpoints=checkpoints, default_checkpoint_path=session.get('default_checkpoint', '')) - -@app.route('/set_default_checkpoint', methods=['POST']) -def set_default_checkpoint(): - checkpoint_path = request.form.get('checkpoint_path', '') - session['default_checkpoint'] = checkpoint_path - session.modified = True - - # Persist to database Settings so it survives across server restarts - try: - settings = Settings.query.first() - if not settings: - settings = Settings() - db.session.add(settings) - settings.default_checkpoint = checkpoint_path - db.session.commit() - logger.info("Default checkpoint saved to database: %s", checkpoint_path) - except Exception as e: - logger.error(f"Failed to persist checkpoint to database: {e}") - db.session.rollback() - - # Also persist to comfy_workflow.json for backwards compatibility - try: - workflow_path = 'comfy_workflow.json' - with open(workflow_path, 'r') as f: - workflow = json.load(f) - - # Update node 4 (CheckpointLoaderSimple) with the new checkpoint - if '4' in workflow and 'inputs' in workflow['4']: - workflow['4']['inputs']['ckpt_name'] = checkpoint_path - - with open(workflow_path, 'w') as f: - json.dump(workflow, f, indent=2) - except Exception as e: - logger.error(f"Failed to persist checkpoint to workflow file: {e}") - - return {'status': 'ok'} - - -@app.route('/api/status/comfyui') -def api_status_comfyui(): - """Return whether ComfyUI is reachable.""" - url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') - try: - resp = requests.get(f'{url}/system_stats', timeout=3) - if resp.ok: - return {'status': 'ok'} - except Exception: - pass - return {'status': 'error'} - - -@app.route('/api/comfyui/loaded_checkpoint') -def api_comfyui_loaded_checkpoint(): - """Return the checkpoint name from the most recently completed ComfyUI job.""" - url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') - try: - resp = requests.get(f'{url}/history', timeout=3) - if not resp.ok: - return {'checkpoint': None} - history = resp.json() - if not history: - return {'checkpoint': None} - # Sort by timestamp descending, take the most recent job - latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) - # Node "4" is the checkpoint loader in the workflow - nodes = latest.get('prompt', [None, None, {}])[2] - ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') - return {'checkpoint': ckpt_name} - except Exception: - return {'checkpoint': None} - - -@app.route('/api/status/mcp') -def api_status_mcp(): - """Return whether the danbooru-mcp Docker container is running.""" - try: - result = subprocess.run( - ['docker', 'ps', '--filter', 'name=danbooru-mcp', '--format', '{{.Names}}'], - capture_output=True, text=True, timeout=5, - ) - if 'danbooru-mcp' in result.stdout: - return {'status': 'ok'} - except Exception: - pass - return {'status': 'error'} - - -@app.route('/api/status/llm') -def api_status_llm(): - """Return whether the configured LLM provider is reachable.""" - try: - settings = Settings.query.first() - if not settings: - return {'status': 'error', 'message': 'Settings not configured'} - - is_local = settings.llm_provider != 'openrouter' - - if not is_local: - # Check OpenRouter - if not settings.openrouter_api_key: - return {'status': 'error', 'message': 'API key not configured'} - - # Try to fetch models list as a lightweight check - headers = { - "Authorization": f"Bearer {settings.openrouter_api_key}", - } - resp = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=5) - if resp.ok: - return {'status': 'ok', 'provider': 'OpenRouter'} - else: - # Check local provider (Ollama or LMStudio) - if not settings.local_base_url: - return {'status': 'error', 'message': 'Base URL not configured'} - - # Try to reach the models endpoint - url = f"{settings.local_base_url.rstrip('/')}/models" - resp = requests.get(url, timeout=5) - if resp.ok: - return {'status': 'ok', 'provider': settings.llm_provider.title()} - except Exception as e: - return {'status': 'error', 'message': str(e)} - - return {'status': 'error'} - -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} - -_LORA_DEFAULTS = { - 'characters': '/ImageModels/lora/Illustrious/Looks', - 'outfits': '/ImageModels/lora/Illustrious/Clothing', - 'actions': '/ImageModels/lora/Illustrious/Poses', - 'styles': '/ImageModels/lora/Illustrious/Styles', - 'scenes': '/ImageModels/lora/Illustrious/Backgrounds', - 'detailers': '/ImageModels/lora/Illustrious/Detailers', -} - -def get_available_loras(category): - """Return sorted list of LoRA paths for the given category. - category: one of 'characters','outfits','actions','styles','scenes','detailers' - """ - settings = Settings.query.first() - lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '') - if not lora_dir or not os.path.isdir(lora_dir): - return [] - subfolder = os.path.basename(lora_dir.rstrip('/')) - return sorted(f"Illustrious/{subfolder}/{f}" for f in os.listdir(lora_dir) if f.endswith('.safetensors')) - -def get_available_checkpoints(): - settings = Settings.query.first() - checkpoint_dirs_str = (settings.checkpoint_dirs if settings else None) or \ - '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' - checkpoints = [] - for ckpt_dir in checkpoint_dirs_str.split(','): - ckpt_dir = ckpt_dir.strip() - if not ckpt_dir or not os.path.isdir(ckpt_dir): - continue - prefix = os.path.basename(ckpt_dir.rstrip('/')) - for f in os.listdir(ckpt_dir): - if f.endswith('.safetensors') or f.endswith('.ckpt'): - checkpoints.append(f"{prefix}/{f}") - return sorted(checkpoints) - -def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -def parse_orientation(orientation_str): - if not orientation_str: return [] - - m_count = orientation_str.upper().count('M') - f_count = orientation_str.upper().count('F') - total = m_count + f_count - - tags = [] - - # Gender counts - if m_count == 1: tags.append("1boy") - elif m_count > 1: tags.append(f"{m_count}boys") - - if f_count == 1: tags.append("1girl") - elif f_count > 1: tags.append(f"{f_count}girls") - - # Relationships/Group type - if total == 1: - tags.append("solo") - elif total > 1: - if m_count > 0 and f_count > 0: - tags.append("hetero") - elif f_count > 1 and m_count == 0: - tags.append("yuri") - elif m_count > 1 and f_count == 0: - tags.append("yaoi") - - return tags - -def _dedup_tags(prompt_str): - """Remove duplicate tags from a comma-separated prompt string, preserving first-occurrence order.""" - seen = set() - result = [] - for tag in prompt_str.split(','): - t = tag.strip() - if t and t.lower() not in seen: - seen.add(t.lower()) - result.append(t) - return ', '.join(result) - -def _cross_dedup_prompts(positive, negative): - """Remove tags shared between positive and negative prompts. - - Repeatedly strips the first occurrence from each side until the tag exists - on only one side. Equal counts cancel out completely; any excess on one side - retains the remainder, allowing deliberate overrides (e.g. adding a tag twice - in the positive while it appears once in the negative leaves one copy positive). - """ - def parse_tags(s): - return [t.strip() for t in s.split(',') if t.strip()] - - pos_tags = parse_tags(positive) - neg_tags = parse_tags(negative) - - shared = {t.lower() for t in pos_tags} & {t.lower() for t in neg_tags} - for tag_lower in shared: - while ( - any(t.lower() == tag_lower for t in pos_tags) and - any(t.lower() == tag_lower for t in neg_tags) - ): - pos_tags.pop(next(i for i, t in enumerate(pos_tags) if t.lower() == tag_lower)) - neg_tags.pop(next(i for i, t in enumerate(neg_tags) if t.lower() == tag_lower)) - - return ', '.join(pos_tags), ', '.join(neg_tags) - -def _resolve_lora_weight(lora_data, override=None): - """Return effective LoRA weight, randomising between min/max when they differ. - - If *override* is provided it takes absolute precedence (used by the Strengths - Gallery to pin a specific value for each step). - """ - if override is not None: - return float(override) - weight = float(lora_data.get('lora_weight', 1.0)) - min_w = lora_data.get('lora_weight_min') - max_w = lora_data.get('lora_weight_max') - if min_w is not None and max_w is not None: - min_w, max_w = float(min_w), float(max_w) - if min_w != max_w: - weight = random.uniform(min(min_w, max_w), max(min_w, max_w)) - return weight - -_IDENTITY_KEYS = ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra'] -_WARDROBE_KEYS = ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories'] - -def _resolve_character(character_slug): - """Resolve a character_slug string (possibly '__random__') to a Character instance.""" - if character_slug == '__random__': - return Character.query.order_by(db.func.random()).first() - if character_slug: - return Character.query.filter_by(slug=character_slug).first() - return None - -def _ensure_character_fields(character, selected_fields, include_wardrobe=True, include_defaults=False): - """Mutate selected_fields in place to include essential character identity/wardrobe/name keys. - - include_wardrobe — also inject active wardrobe keys (default True) - include_defaults — also inject defaults::expression and defaults::pose (for outfit/look previews) - """ - identity = character.data.get('identity', {}) - for key in _IDENTITY_KEYS: - if identity.get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - if include_defaults: - for key in ['expression', 'pose']: - if character.data.get('defaults', {}).get(key): - field_key = f'defaults::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - if 'special::name' not in selected_fields: - selected_fields.append('special::name') - if include_wardrobe: - wardrobe = character.get_active_wardrobe() - for key in _WARDROBE_KEYS: - if wardrobe.get(key): - field_key = f'wardrobe::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - -def _append_background(prompts, character=None): - """Append a (color-prefixed) simple background tag to prompts['main'].""" - primary_color = character.data.get('styles', {}).get('primary_color', '') if character else '' - bg = f"{primary_color} simple background" if primary_color else "simple background" - prompts['main'] = f"{prompts['main']}, {bg}" - -def _count_look_assignments(): - """Return a dict mapping look_id to the count of characters it's assigned to.""" - # Looks are assigned via the character_id field in the Look model - assignment_counts = {} - looks = Look.query.all() - for look in looks: - if look.character_id: - assignment_counts[look.look_id] = 1 # Each look is assigned to at most one character - else: - assignment_counts[look.look_id] = 0 - return assignment_counts - -def _count_outfit_lora_assignments(): - """Return a dict mapping outfit LoRA filename to the count of characters using it.""" - assignment_counts = {} - characters = Character.query.all() - - for character in characters: - # Check character's own LoRA (in case it's actually an outfit LoRA) - char_lora = character.data.get('lora', {}).get('lora_name', '') - if char_lora and 'Clothing' in char_lora: - assignment_counts[char_lora] = assignment_counts.get(char_lora, 0) + 1 - - # Check all wardrobe outfits for LoRA references - wardrobe = character.data.get('wardrobe', {}) - # Handle both nested (new) and flat (legacy) wardrobe formats - if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): - # New nested format - check each outfit - for outfit_name, outfit_data in wardrobe.items(): - if isinstance(outfit_data, dict): - outfit_lora = outfit_data.get('lora', {}) - if isinstance(outfit_lora, dict): - lora_name = outfit_lora.get('lora_name', '') - if lora_name: - assignment_counts[lora_name] = assignment_counts.get(lora_name, 0) + 1 - - return assignment_counts - -def build_prompt(data, selected_fields=None, default_fields=None, active_outfit='default'): - def is_selected(section, key): - # Priority: - # 1. Manual selection from form (if list is not empty) - # 2. Database defaults (if they exist) - # 3. Select all (default behavior) - if selected_fields: - return f"{section}::{key}" in selected_fields - if default_fields: - return f"{section}::{key}" in default_fields - return True - - identity = data.get('identity', {}) - - # Get wardrobe - handle both new nested format and legacy flat format - wardrobe_data = data.get('wardrobe', {}) - if 'default' in wardrobe_data and isinstance(wardrobe_data.get('default'), dict): - # New nested format - get active outfit - wardrobe = wardrobe_data.get(active_outfit or 'default', wardrobe_data.get('default', {})) - else: - # Legacy flat format - wardrobe = wardrobe_data - - defaults = data.get('defaults', {}) - action_data = data.get('action', {}) - style_data = data.get('style', {}) - participants = data.get('participants', {}) - - # Pre-calculate Hand/Glove priority - # Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character) - hand_val = "" - if wardrobe.get('gloves') and is_selected('wardrobe', 'gloves'): - hand_val = wardrobe.get('gloves') - elif wardrobe.get('hands') and is_selected('wardrobe', 'hands'): - hand_val = wardrobe.get('hands') - elif identity.get('hands') and is_selected('identity', 'hands'): - hand_val = identity.get('hands') - - # 1. Main Prompt - parts = [] - - # Handle participants logic - if participants: - if participants.get('solo_focus') == 'true': - parts.append('(solo focus:1.2)') - - orientation = participants.get('orientation', '') - if orientation: - parts.extend(parse_orientation(orientation)) - else: - # Default behavior - parts.append("(solo:1.2)") - - # Use character_id (underscores to spaces) for tags compatibility - char_tag = data.get('character_id', '').replace('_', ' ') - if char_tag and is_selected('special', 'name'): - parts.append(char_tag) - - for key in ['base_specs', 'hair', 'eyes', 'extra']: - val = identity.get(key) - if val and is_selected('identity', key): - # Filter out conflicting tags if participants data is present - if participants and key == 'base_specs': - # Remove 1girl, 1boy, solo, etc. - val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ') - parts.append(val) - - # Add defaults (expression, pose, scene) - for key in ['expression', 'pose', 'scene']: - val = defaults.get(key) - if val and is_selected('defaults', key): - parts.append(val) - - # Add hand priority value to main prompt - if hand_val: - parts.append(hand_val) - - for key in ['full_body', 'top', 'bottom', 'headwear', 'legwear', 'footwear', 'accessories']: - val = wardrobe.get(key) - if val and is_selected('wardrobe', key): - parts.append(val) - - # Standard character styles - char_aesthetic = data.get('styles', {}).get('aesthetic') - if char_aesthetic and is_selected('styles', 'aesthetic'): - parts.append(f"{char_aesthetic} style") - - # New Styles Gallery logic - if style_data.get('artist_name') and is_selected('style', 'artist_name'): - parts.append(f"by {style_data['artist_name']}") - 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')) - - # 2. Face Prompt: Tag, Eyes, Expression, Headwear, Action details - face_parts = [] - if char_tag and is_selected('special', 'name'): face_parts.append(char_tag) - if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes')) - if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression')) - if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear')) - - # Add specific Action expression details if available - if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head')) - if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes')) - - # 3. Hand Prompt: Hand value (Gloves or Hands), Action details - hand_parts = [hand_val] if hand_val else [] - if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms')) - if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands')) - - return { - "main": _dedup_tags(", ".join(parts)), - "face": _dedup_tags(", ".join(face_parts)), - "hand": _dedup_tags(", ".join(hand_parts)) - } - -def _ensure_checkpoint_loaded(checkpoint_path): - """Check if the desired checkpoint is loaded in ComfyUI, and force reload if not.""" - if not checkpoint_path: - return - - try: - # Get currently loaded checkpoint from ComfyUI history - url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') - resp = requests.get(f'{url}/history', timeout=3) - if resp.ok: - history = resp.json() - if history: - latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) - nodes = latest.get('prompt', [None, None, {}])[2] - loaded_ckpt = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') - - # If the loaded checkpoint matches what we want, no action needed - if loaded_ckpt == checkpoint_path: - logger.info(f"Checkpoint {checkpoint_path} already loaded in ComfyUI") - return - - # Checkpoint doesn't match or couldn't determine - force unload all models - logger.info(f"Forcing ComfyUI to unload models to ensure {checkpoint_path} loads") - requests.post(f'{url}/free', json={'unload_models': True}, timeout=5) - except Exception as e: - logger.warning(f"Failed to check/force checkpoint reload: {e}") - -def queue_prompt(prompt_workflow, client_id=None): - # Ensure the checkpoint in the workflow is loaded in ComfyUI - checkpoint_path = prompt_workflow.get('4', {}).get('inputs', {}).get('ckpt_name') - _ensure_checkpoint_loaded(checkpoint_path) - - p = {"prompt": prompt_workflow} - if client_id: - p["client_id"] = client_id - - # Log the full request being sent to ComfyUI - logger.debug("=" * 80) - logger.debug("COMFYUI REQUEST - Sending prompt to %s/prompt", app.config['COMFYUI_URL']) - logger.debug("Checkpoint: %s", checkpoint_path) - logger.debug("Client ID: %s", client_id if client_id else "(none)") - logger.debug("Full workflow JSON:") - logger.debug(json.dumps(prompt_workflow, indent=2)) - logger.debug("=" * 80) - - data = json.dumps(p).encode('utf-8') - response = requests.post(f"{app.config['COMFYUI_URL']}/prompt", data=data) - response_json = response.json() - - # Log the response from ComfyUI - logger.debug("COMFYUI RESPONSE - Status: %s", response.status_code) - logger.debug("Response JSON: %s", json.dumps(response_json, indent=2)) - if 'prompt_id' in response_json: - logger.info("ComfyUI accepted prompt with ID: %s", response_json['prompt_id']) - else: - logger.error("ComfyUI rejected prompt: %s", response_json) - logger.debug("=" * 80) - - return response_json - -def get_history(prompt_id): - response = requests.get(f"{app.config['COMFYUI_URL']}/history/{prompt_id}") - history_json = response.json() - - # Log detailed history response for debugging - if prompt_id in history_json: - logger.debug("=" * 80) - logger.debug("COMFYUI HISTORY - Prompt ID: %s", prompt_id) - logger.debug("Status: %s", response.status_code) - - # Extract key information from the history - prompt_data = history_json[prompt_id] - if 'status' in prompt_data: - logger.debug("Generation status: %s", prompt_data['status']) - - if 'outputs' in prompt_data: - logger.debug("Outputs available: %s", list(prompt_data['outputs'].keys())) - for node_id, output in prompt_data['outputs'].items(): - if 'images' in output: - logger.debug(" Node %s produced %d image(s)", node_id, len(output['images'])) - for img in output['images']: - logger.debug(" - %s (subfolder: %s, type: %s)", - img.get('filename'), img.get('subfolder'), img.get('type')) - - logger.debug("Full history response:") - logger.debug(json.dumps(history_json, indent=2)) - logger.debug("=" * 80) - else: - logger.debug("History not yet available for prompt ID: %s", prompt_id) - - return history_json - -def get_image(filename, subfolder, folder_type): - data = {"filename": filename, "subfolder": subfolder, "type": folder_type} - logger.debug("Fetching image from ComfyUI: filename=%s, subfolder=%s, type=%s", - filename, subfolder, folder_type) - response = requests.get(f"{app.config['COMFYUI_URL']}/view", params=data) - logger.debug("Image retrieved: %d bytes (status: %s)", len(response.content), response.status_code) - return response.content - -from sqlalchemy.orm.attributes import flag_modified - -def sync_characters(): - if not os.path.exists(app.config['CHARACTERS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['CHARACTERS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['CHARACTERS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - char_id = data.get('character_id') - if not char_id: - continue - - current_ids.append(char_id) - - # Generate URL-safe slug: remove special characters from character_id - slug = re.sub(r'[^a-zA-Z0-9_]', '', char_id) - - # Check if character already exists - character = Character.query.filter_by(character_id=char_id).first() - name = data.get('character_name', char_id.replace('_', ' ').title()) - - if character: - character.data = data - character.name = name - character.slug = slug - character.filename = filename - - # Check if cover image still exists - if character.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], character.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {character.name}, clearing path.") - character.image_path = None - - # Explicitly tell SQLAlchemy the JSON field was modified - flag_modified(character, "data") - else: - new_char = Character( - character_id=char_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_char) - except Exception as e: - print(f"Error importing {filename}: {e}") - - # Remove characters that are no longer in the folder - all_characters = Character.query.all() - for char in all_characters: - if char.character_id not in current_ids: - db.session.delete(char) - - db.session.commit() - -def sync_outfits(): - if not os.path.exists(app.config['CLOTHING_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['CLOTHING_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['CLOTHING_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - outfit_id = data.get('outfit_id') or filename.replace('.json', '') - - current_ids.append(outfit_id) - - # Generate URL-safe slug: remove special characters from outfit_id - slug = re.sub(r'[^a-zA-Z0-9_]', '', outfit_id) - - # Check if outfit already exists - outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() - name = data.get('outfit_name', outfit_id.replace('_', ' ').title()) - - if outfit: - outfit.data = data - outfit.name = name - outfit.slug = slug - outfit.filename = filename - - # Check if cover image still exists - if outfit.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], outfit.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {outfit.name}, clearing path.") - outfit.image_path = None - - # Explicitly tell SQLAlchemy the JSON field was modified - flag_modified(outfit, "data") - else: - new_outfit = Outfit( - outfit_id=outfit_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_outfit) - except Exception as e: - print(f"Error importing outfit {filename}: {e}") - - # Remove outfits that are no longer in the folder - all_outfits = Outfit.query.all() - for outfit in all_outfits: - if outfit.outfit_id not in current_ids: - db.session.delete(outfit) - - db.session.commit() - -def sync_looks(): - if not os.path.exists(app.config['LOOKS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['LOOKS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['LOOKS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - look_id = data.get('look_id') or filename.replace('.json', '') - - current_ids.append(look_id) - - slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id) - - look = Look.query.filter_by(look_id=look_id).first() - name = data.get('look_name', look_id.replace('_', ' ').title()) - character_id = data.get('character_id', None) - - if look: - look.data = data - look.name = name - look.slug = slug - look.filename = filename - look.character_id = character_id - - if look.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], look.image_path) - if not os.path.exists(full_img_path): - look.image_path = None - - flag_modified(look, "data") - else: - new_look = Look( - look_id=look_id, - slug=slug, - filename=filename, - name=name, - character_id=character_id, - data=data - ) - db.session.add(new_look) - except Exception as e: - print(f"Error importing look {filename}: {e}") - - all_looks = Look.query.all() - for look in all_looks: - if look.look_id not in current_ids: - db.session.delete(look) - - db.session.commit() - -def sync_presets(): - if not os.path.exists(app.config['PRESETS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['PRESETS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['PRESETS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - preset_id = data.get('preset_id') or filename.replace('.json', '') - - current_ids.append(preset_id) - - slug = re.sub(r'[^a-zA-Z0-9_]', '', preset_id) - - preset = Preset.query.filter_by(preset_id=preset_id).first() - name = data.get('preset_name', preset_id.replace('_', ' ').title()) - - if preset: - preset.data = data - preset.name = name - preset.slug = slug - preset.filename = filename - - if preset.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], preset.image_path) - if not os.path.exists(full_img_path): - preset.image_path = None - - flag_modified(preset, "data") - else: - new_preset = Preset( - preset_id=preset_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_preset) - except Exception as e: - print(f"Error importing preset {filename}: {e}") - - all_presets = Preset.query.all() - for preset in all_presets: - if preset.preset_id not in current_ids: - db.session.delete(preset) - - db.session.commit() - - -# --------------------------------------------------------------------------- -# Preset helpers -# --------------------------------------------------------------------------- - -_PRESET_ENTITY_MAP = { - 'character': (Character, 'character_id'), - 'outfit': (Outfit, 'outfit_id'), - 'action': (Action, 'action_id'), - 'style': (Style, 'style_id'), - 'scene': (Scene, 'scene_id'), - 'detailer': (Detailer, 'detailer_id'), - 'look': (Look, 'look_id'), - 'checkpoint': (Checkpoint, 'checkpoint_path'), -} - - -def _resolve_preset_entity(entity_type, entity_id): - """Resolve a preset entity_id ('random', specific ID, or None) to an ORM object.""" - if not entity_id: - return None - model_class, id_field = _PRESET_ENTITY_MAP[entity_type] - if entity_id == 'random': - return model_class.query.order_by(db.func.random()).first() - return model_class.query.filter(getattr(model_class, id_field) == entity_id).first() - - -def _resolve_preset_fields(preset_data): - """Convert preset field toggle dicts into a selected_fields list. - - Each field value: True = include, False = exclude, 'random' = randomly decide. - Returns a list of 'section::key' strings for fields that are active. - """ - selected = [] - char_cfg = preset_data.get('character', {}) - fields = char_cfg.get('fields', {}) - - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - val = fields.get('identity', {}).get(key, True) - if val == 'random': - val = random.choice([True, False]) - if val: - selected.append(f'identity::{key}') - - for key in ['expression', 'pose', 'scene']: - val = fields.get('defaults', {}).get(key, False) - if val == 'random': - val = random.choice([True, False]) - if val: - selected.append(f'defaults::{key}') - - wardrobe_cfg = fields.get('wardrobe', {}) - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - val = wardrobe_cfg.get('fields', {}).get(key, True) - if val == 'random': - val = random.choice([True, False]) - if val: - selected.append(f'wardrobe::{key}') - - # Always include name and lora triggers - selected.append('special::name') - if char_cfg.get('use_lora', True): - selected.append('lora::lora_triggers') - - return selected - - -def sync_actions(): - if not os.path.exists(app.config['ACTIONS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['ACTIONS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['ACTIONS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - action_id = data.get('action_id') or filename.replace('.json', '') - - current_ids.append(action_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id) - - # Check if action already exists - action = Action.query.filter_by(action_id=action_id).first() - name = data.get('action_name', action_id.replace('_', ' ').title()) - - if action: - action.data = data - action.name = name - action.slug = slug - action.filename = filename - - # Check if cover image still exists - if action.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], action.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {action.name}, clearing path.") - action.image_path = None - - flag_modified(action, "data") - else: - new_action = Action( - action_id=action_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_action) - except Exception as e: - print(f"Error importing action {filename}: {e}") - - # Remove actions that are no longer in the folder - all_actions = Action.query.all() - for action in all_actions: - if action.action_id not in current_ids: - db.session.delete(action) - - db.session.commit() - -def sync_styles(): - if not os.path.exists(app.config['STYLES_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['STYLES_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['STYLES_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - style_id = data.get('style_id') or filename.replace('.json', '') - - current_ids.append(style_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', style_id) - - # Check if style already exists - style = Style.query.filter_by(style_id=style_id).first() - name = data.get('style_name', style_id.replace('_', ' ').title()) - - if style: - style.data = data - style.name = name - style.slug = slug - style.filename = filename - - # Check if cover image still exists - if style.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], style.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {style.name}, clearing path.") - style.image_path = None - - flag_modified(style, "data") - else: - new_style = Style( - style_id=style_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_style) - except Exception as e: - print(f"Error importing style {filename}: {e}") - - # Remove styles that are no longer in the folder - all_styles = Style.query.all() - for style in all_styles: - if style.style_id not in current_ids: - db.session.delete(style) - - db.session.commit() - -def sync_detailers(): - if not os.path.exists(app.config['DETAILERS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['DETAILERS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['DETAILERS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - detailer_id = data.get('detailer_id') or filename.replace('.json', '') - - current_ids.append(detailer_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', detailer_id) - - # Check if detailer already exists - detailer = Detailer.query.filter_by(detailer_id=detailer_id).first() - name = data.get('detailer_name', detailer_id.replace('_', ' ').title()) - - if detailer: - detailer.data = data - detailer.name = name - detailer.slug = slug - detailer.filename = filename - - # Check if cover image still exists - if detailer.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], detailer.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {detailer.name}, clearing path.") - detailer.image_path = None - - flag_modified(detailer, "data") - else: - new_detailer = Detailer( - detailer_id=detailer_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_detailer) - except Exception as e: - print(f"Error importing detailer {filename}: {e}") - - # Remove detailers that are no longer in the folder - all_detailers = Detailer.query.all() - for detailer in all_detailers: - if detailer.detailer_id not in current_ids: - db.session.delete(detailer) - - db.session.commit() - -def sync_scenes(): - if not os.path.exists(app.config['SCENES_DIR']): - return - - current_ids = [] - - for filename in os.listdir(app.config['SCENES_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(app.config['SCENES_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - scene_id = data.get('scene_id') or filename.replace('.json', '') - - current_ids.append(scene_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', scene_id) - - # Check if scene already exists - scene = Scene.query.filter_by(scene_id=scene_id).first() - name = data.get('scene_name', scene_id.replace('_', ' ').title()) - - if scene: - scene.data = data - scene.name = name - scene.slug = slug - scene.filename = filename - - # Check if cover image still exists - if scene.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], scene.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {scene.name}, clearing path.") - scene.image_path = None - - flag_modified(scene, "data") - else: - new_scene = Scene( - scene_id=scene_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_scene) - except Exception as e: - print(f"Error importing scene {filename}: {e}") - - # Remove scenes that are no longer in the folder - all_scenes = Scene.query.all() - for scene in all_scenes: - if scene.scene_id not in current_ids: - db.session.delete(scene) - - db.session.commit() - -def _default_checkpoint_data(checkpoint_path, filename): - """Return template-default data for a checkpoint with no JSON file.""" - name_base = filename.rsplit('.', 1)[0] - return { - "checkpoint_path": checkpoint_path, - "checkpoint_name": filename, - "base_positive": "anime", - "base_negative": "text, logo", - "steps": 25, - "cfg": 5, - "sampler_name": "euler_ancestral", - "vae": "integrated" - } - -def sync_checkpoints(): - checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') - os.makedirs(checkpoints_dir, exist_ok=True) - - # Load all JSON data files keyed by checkpoint_path - json_data_by_path = {} - for filename in os.listdir(checkpoints_dir): - if filename.endswith('.json') and not filename.endswith('.template'): - file_path = os.path.join(checkpoints_dir, filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - ckpt_path = data.get('checkpoint_path') - if ckpt_path: - json_data_by_path[ckpt_path] = data - except Exception as e: - print(f"Error reading checkpoint JSON {filename}: {e}") - - current_ids = [] - dirs = [ - (app.config.get('ILLUSTRIOUS_MODELS_DIR', ''), 'Illustrious'), - (app.config.get('NOOB_MODELS_DIR', ''), 'Noob'), - ] - for dirpath, family in dirs: - if not dirpath or not os.path.exists(dirpath): - continue - for f in sorted(os.listdir(dirpath)): - if not (f.endswith('.safetensors') or f.endswith('.ckpt')): - continue - checkpoint_path = f"{family}/{f}" - checkpoint_id = checkpoint_path - slug = re.sub(r'[^a-zA-Z0-9_]', '_', checkpoint_path.rsplit('.', 1)[0]).lower().strip('_') - name_base = f.rsplit('.', 1)[0] - friendly_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).strip().title() - current_ids.append(checkpoint_id) - - data = json_data_by_path.get(checkpoint_path, - _default_checkpoint_data(checkpoint_path, f)) - display_name = data.get('checkpoint_name', f).rsplit('.', 1)[0] - display_name = re.sub(r'[^a-zA-Z0-9]+', ' ', display_name).strip().title() or friendly_name - - ckpt = Checkpoint.query.filter_by(checkpoint_id=checkpoint_id).first() - if ckpt: - ckpt.name = display_name - ckpt.slug = slug - ckpt.checkpoint_path = checkpoint_path - ckpt.data = data - flag_modified(ckpt, "data") - if ckpt.image_path: - full_img_path = os.path.join(app.config['UPLOAD_FOLDER'], ckpt.image_path) - if not os.path.exists(full_img_path): - ckpt.image_path = None - else: - db.session.add(Checkpoint( - checkpoint_id=checkpoint_id, - slug=slug, - name=display_name, - checkpoint_path=checkpoint_path, - data=data, - )) - - all_ckpts = Checkpoint.query.all() - for ckpt in all_ckpts: - if ckpt.checkpoint_id not in current_ids: - db.session.delete(ckpt) - - db.session.commit() - -DANBOORU_TOOLS = [ - { - "type": "function", - "function": { - "name": "search_tags", - "description": "Prefix/full-text search for Danbooru tags. Returns rich tag objects ordered by relevance.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search string. Trailing * added automatically."}, - "limit": {"type": "integer", "description": "Max results (1-200)", "default": 20}, - "category": {"type": "string", "enum": ["general", "artist", "copyright", "character", "meta"], "description": "Optional category filter."} - }, - "required": ["query"] - } - } - }, - { - "type": "function", - "function": { - "name": "validate_tags", - "description": "Exact-match validation for a list of tags. Splits into valid, deprecated, and invalid.", - "parameters": { - "type": "object", - "properties": { - "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to validate."} - }, - "required": ["tags"] - } - } - }, - { - "type": "function", - "function": { - "name": "suggest_tags", - "description": "Autocomplete-style suggestions for a partial or approximate tag. Sorted by post count.", - "parameters": { - "type": "object", - "properties": { - "partial": {"type": "string", "description": "Partial tag or rough approximation."}, - "limit": {"type": "integer", "description": "Max suggestions (1-50)", "default": 10}, - "category": {"type": "string", "enum": ["general", "artist", "copyright", "character", "meta"], "description": "Optional category filter."} - }, - "required": ["partial"] - } - } - } -] - -async def _run_mcp_tool(name, arguments): - server_params = StdioServerParameters( - command="docker", - args=["run", "--rm", "-i", "danbooru-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_mcp_tool(name, arguments): - try: - return asyncio.run(_run_mcp_tool(name, arguments)) - except Exception as e: - print(f"MCP Tool Error: {e}") - return json.dumps({"error": str(e)}) - -def load_prompt(filename): - path = os.path.join('data/prompts', filename) - if os.path.exists(path): - with open(path, 'r') as f: - return f.read() - return None - -def call_llm(prompt, system_prompt="You are a creative assistant."): - settings = Settings.query.first() - if not settings: - raise ValueError("Settings not configured.") - - is_local = settings.llm_provider != 'openrouter' - - if not is_local: - if not settings.openrouter_api_key: - raise ValueError("OpenRouter API Key not configured. Please configure it in Settings.") - - url = "https://openrouter.ai/api/v1/chat/completions" - headers = { - "Authorization": f"Bearer {settings.openrouter_api_key}", - "Content-Type": "application/json", - "HTTP-Referer": request.url_root, - "X-Title": "Character Browser" - } - model = settings.openrouter_model or 'google/gemini-2.0-flash-001' - else: - # Local provider (Ollama or LMStudio) - if not settings.local_base_url: - raise ValueError(f"{settings.llm_provider.title()} Base URL not configured.") - - url = f"{settings.local_base_url.rstrip('/')}/chat/completions" - headers = {"Content-Type": "application/json"} - model = settings.local_model - if not model: - raise ValueError(f"No local model selected for {settings.llm_provider.title()}. Please select one in Settings.") - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt} - ] - - max_turns = 10 - use_tools = True - format_retries = 3 # retries allowed for unexpected response format - - while max_turns > 0: - max_turns -= 1 - data = { - "model": model, - "messages": messages, - } - - # Only add tools if supported/requested - if use_tools: - data["tools"] = DANBOORU_TOOLS - data["tool_choice"] = "auto" - - try: - response = requests.post(url, headers=headers, json=data) - - # If 400 Bad Request and we were using tools, try once without tools - if response.status_code == 400 and use_tools: - print(f"LLM Provider {settings.llm_provider} rejected tools. Retrying without tool calling...") - use_tools = False - max_turns += 1 # Reset turn for the retry - continue - - response.raise_for_status() - result = response.json() - - # Validate expected OpenAI-compatible response shape - if 'choices' not in result or not result['choices']: - raise KeyError('choices') - - message = result['choices'][0].get('message') - if message is None: - raise KeyError('message') - - if message.get('tool_calls'): - messages.append(message) - for tool_call in message['tool_calls']: - name = tool_call['function']['name'] - args = json.loads(tool_call['function']['arguments']) - print(f"Executing MCP tool: {name}({args})") - tool_result = call_mcp_tool(name, args) - messages.append({ - "role": "tool", - "tool_call_id": tool_call['id'], - "name": name, - "content": tool_result - }) - continue - - return message['content'] - except requests.exceptions.RequestException as e: - error_body = "" - try: error_body = f" - Body: {response.text}" - except: pass - raise RuntimeError(f"LLM API request failed: {str(e)}{error_body}") from e - except (KeyError, IndexError) as e: - # Log the raw response to help diagnose the issue - raw = "" - try: raw = response.text[:500] - except: pass - print(f"Unexpected LLM response format (key={e}). Raw response: {raw}") - if format_retries > 0: - format_retries -= 1 - max_turns += 1 # don't burn a turn on a format error - # Ask the model to try again with the correct format - messages.append({ - "role": "user", - "content": ( - "Your previous response was not in the expected format. " - "Please respond with valid JSON only, exactly as specified in the system prompt. " - "Do not include any explanation or markdown — only the raw JSON object." - ) - }) - print(f"Retrying after format error ({format_retries} retries left)…") - continue - raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e - - raise RuntimeError("LLM tool calling loop exceeded maximum turns") - -@app.route('/get_openrouter_models', methods=['POST']) -def get_openrouter_models(): - api_key = request.form.get('api_key') - if not api_key: - return {'error': 'API key is required'}, 400 - - headers = {"Authorization": f"Bearer {api_key}"} - try: - response = requests.get("https://openrouter.ai/api/v1/models", headers=headers) - response.raise_for_status() - models = response.json().get('data', []) - # Return simplified list of models - return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]} - except Exception as e: - return {'error': str(e)}, 500 - -@app.route('/get_local_models', methods=['POST']) -def get_local_models(): - base_url = request.form.get('base_url') - if not base_url: - return {'error': 'Base URL is required'}, 400 - - try: - response = requests.get(f"{base_url.rstrip('/')}/models") - response.raise_for_status() - models = response.json().get('data', []) - # Ollama/LMStudio often follow the same structure as OpenAI - return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]} - except Exception as e: - return {'error': str(e)}, 500 - -@app.route('/settings', methods=['GET', 'POST']) -def settings(): - settings = Settings.query.first() - if not settings: - settings = Settings() - db.session.add(settings) - db.session.commit() - - if request.method == 'POST': - settings.llm_provider = request.form.get('llm_provider', 'openrouter') - settings.openrouter_api_key = request.form.get('api_key') - settings.openrouter_model = request.form.get('model') - settings.local_base_url = request.form.get('local_base_url') - settings.local_model = request.form.get('local_model') - settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters - settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits - settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions - settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles - settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes - settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers - settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs - db.session.commit() - flash('Settings updated successfully!') - return redirect(url_for('settings')) - - return render_template('settings.html', settings=settings) - -@app.route('/') -def index(): - characters = Character.query.order_by(Character.name).all() - return render_template('index.html', characters=characters) - -@app.route('/rescan', methods=['POST']) -def rescan(): - sync_characters() - flash('Database synced with character files.') - return redirect(url_for('index')) - -def build_extras_prompt(actions, outfits, scenes, styles, detailers): - """Combine positive prompt text from all selected category items.""" - parts = [] - - for action in actions: - data = action.data - lora = data.get('lora', {}) - if lora.get('lora_triggers'): - parts.append(lora['lora_triggers']) - parts.extend(data.get('tags', [])) - for key in ['full_body', 'additional']: - val = data.get('action', {}).get(key) - if val: - parts.append(val) - - for outfit in outfits: - data = outfit.data - wardrobe = data.get('wardrobe', {}) - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories']: - val = wardrobe.get(key) - if val: - parts.append(val) - 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 - scene_fields = data.get('scene', {}) - for key in ['background', 'foreground', 'lighting']: - val = scene_fields.get(key) - if val: - parts.append(val) - 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 - style_fields = data.get('style', {}) - if style_fields.get('artist_name'): - parts.append(f"by {style_fields['artist_name']}") - if style_fields.get('artistic_style'): - parts.append(style_fields['artistic_style']) - lora = data.get('lora', {}) - if lora.get('lora_triggers'): - parts.append(lora['lora_triggers']) - - for detailer in detailers: - data = detailer.data - prompt = data.get('prompt', '') - if isinstance(prompt, list): - parts.extend(p for p in prompt if p) - elif prompt: - parts.append(prompt) - lora = data.get('lora', {}) - if lora.get('lora_triggers'): - parts.append(lora['lora_triggers']) - - return ", ".join(p for p in parts if p) - - -@app.route('/generator', methods=['GET', 'POST']) -def generator(): - characters = Character.query.order_by(Character.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"] - - 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', '') - - 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 - - character = Character.query.filter_by(slug=char_slug).first_or_404() - - 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 [] - - try: - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - # Build base prompts from character defaults - prompts = build_prompt(character.data, default_fields=character.default_fields) - - if override_prompt: - prompts["main"] = override_prompt - else: - extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers) - combined = prompts["main"] - if extras: - combined = f"{combined}, {extras}" - if custom_positive: - combined = f"{combined}, {custom_positive}" - prompts["main"] = combined - - # 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, - ) - - print(f"Queueing generator prompt for {character.character_id}") - - _finalize = _make_finalize('characters', character.slug) - label = f"Generator: {character.name}" - job = _enqueue_job(label, workflow, _finalize) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - 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)}") - - return render_template('generator.html', characters=characters, checkpoints=checkpoints, - actions=actions, outfits=outfits, scenes=scenes, - styles=styles, detailers=detailers) - -@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 - - character = Character.query.filter_by(slug=char_slug).first() - if not character: - return {'error': 'Character not found'}, 404 - - 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', '') - - 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"{combined}, {custom_positive}" - - return {'prompt': combined} - -@app.route('/character/') -def detail(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - - # Load state from session - preferences = session.get(f'prefs_{slug}') - preview_image = session.get(f'preview_{slug}') - - return render_template('detail.html', character=character, preferences=preferences, preview_image=preview_image) - -@app.route('/create', methods=['GET', 'POST']) -def create_character(): - 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' - - # Auto-generate slug from name if not provided - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - # Validate slug - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'character' - - # Find available filename (increment if exists) - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - # Check if LLM generation is requested - if use_llm: - if not prompt: - flash("Description is required when AI generation is enabled.") - return redirect(request.url) - - # Generate JSON with LLM - system_prompt = load_prompt('character_system.txt') - if not system_prompt: - flash("System prompt file not found.") - return redirect(request.url) - - try: - llm_response = call_llm(f"Create a character profile for '{name}' based on this description: {prompt}", system_prompt) - - # Clean response (remove markdown if present) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - char_data = json.loads(clean_json) - - # Enforce IDs - char_data['character_id'] = safe_slug - char_data['character_name'] = name - - except Exception as e: - print(f"LLM error: {e}") - flash(f"Failed to generate character profile: {e}") - return redirect(request.url) - else: - # Create blank character template - char_data = { - "character_id": safe_slug, - "character_name": name, - "identity": { - "base_specs": "", - "hair": "", - "eyes": "", - "hands": "", - "arms": "", - "torso": "", - "pelvis": "", - "legs": "", - "feet": "", - "extra": "" - }, - "defaults": { - "expression": "", - "pose": "", - "scene": "" - }, - "wardrobe": { - "full_body": "", - "headwear": "", - "top": "", - "bottom": "", - "legwear": "", - "footwear": "", - "hands": "", - "accessories": "" - }, - "styles": { - "aesthetic": "", - "primary_color": "", - "secondary_color": "", - "tertiary_color": "" - }, - "lora": { - "lora_name": "", - "lora_weight": 1.0, - "lora_triggers": "" - }, - "tags": [] - } - - try: - # Save file - file_path = os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(char_data, f, indent=2) - - # Add to DB - new_char = Character( - character_id=safe_slug, - slug=safe_slug, - filename=f"{safe_slug}.json", - name=name, - data=char_data - ) - db.session.add(new_char) - db.session.commit() - - flash('Character created successfully!') - return redirect(url_for('detail', slug=safe_slug)) - - except Exception as e: - print(f"Save error: {e}") - flash(f"Failed to create character: {e}") - return redirect(request.url) - - return render_template('create.html') - -@app.route('/character//edit', methods=['GET', 'POST']) -def edit_character(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('characters') - char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all() - - if request.method == 'POST': - try: - # 1. Update basic fields - character.name = request.form.get('character_name') - - # 2. Rebuild the data dictionary - new_data = character.data.copy() - new_data['character_name'] = character.name - - # Update nested sections (non-wardrobe) - for section in ['identity', 'defaults', 'styles', 'lora']: - if section in new_data: - for key in new_data[section]: - form_key = f"{section}_{key}" - if form_key in request.form: - val = request.form.get(form_key) - # Handle numeric weight - if key == 'lora_weight': - try: val = float(val) - except: val = 1.0 - new_data[section][key] = val - - # LoRA weight randomization bounds (new fields not present in existing JSON) - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - else: - new_data.setdefault('lora', {}).pop(bound, None) - - # Handle wardrobe - support both nested and flat formats - wardrobe = new_data.get('wardrobe', {}) - if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): - # New nested format - update each outfit - for outfit_name in wardrobe.keys(): - for key in wardrobe[outfit_name].keys(): - form_key = f"wardrobe_{outfit_name}_{key}" - if form_key in request.form: - wardrobe[outfit_name][key] = request.form.get(form_key) - new_data['wardrobe'] = wardrobe - else: - # Legacy flat format - if 'wardrobe' in new_data: - for key in new_data['wardrobe'].keys(): - form_key = f"wardrobe_{key}" - 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] - - character.data = new_data - flag_modified(character, "data") - - # 3. Write back to JSON file - # Use the filename we stored during sync, or fallback to a sanitized ID - char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" - file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Character profile updated successfully!') - return redirect(url_for('detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('edit.html', character=character, loras=loras, char_looks=char_looks) - -@app.route('/character//outfit/switch', methods=['POST']) -def switch_outfit(slug): - """Switch the active outfit for a character.""" - character = Character.query.filter_by(slug=slug).first_or_404() - outfit_name = request.form.get('outfit', 'default') - - # Validate outfit exists - available_outfits = character.get_available_outfits() - if outfit_name in available_outfits: - character.active_outfit = outfit_name - db.session.commit() - flash(f'Switched to "{outfit_name}" outfit.') - else: - flash(f'Outfit "{outfit_name}" not found.', 'error') - - return redirect(url_for('detail', slug=slug)) - -@app.route('/character//outfit/add', methods=['POST']) -def add_outfit(slug): - """Add a new outfit to a character.""" - character = Character.query.filter_by(slug=slug).first_or_404() - outfit_name = request.form.get('outfit_name', '').strip() - - if not outfit_name: - flash('Outfit name cannot be empty.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - # Sanitize outfit name for use as key - safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', outfit_name.lower()) - - # Get wardrobe data - wardrobe = character.data.get('wardrobe', {}) - - # Ensure wardrobe is in new nested format - if 'default' not in wardrobe or not isinstance(wardrobe.get('default'), dict): - # Convert legacy format - wardrobe = {'default': wardrobe} - - # Check if outfit already exists - if safe_name in wardrobe: - flash(f'Outfit "{safe_name}" already exists.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - # Create new outfit (copy from default as template) - default_outfit = wardrobe.get('default', { - 'headwear': '', 'top': '', 'legwear': '', - 'footwear': '', 'hands': '', 'accessories': '' - }) - wardrobe[safe_name] = default_outfit.copy() - - # Update character data - character.data['wardrobe'] = wardrobe - flag_modified(character, 'data') - - # Save to JSON file - char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" - file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) - with open(file_path, 'w') as f: - json.dump(character.data, f, indent=2) - - db.session.commit() - flash(f'Added new outfit "{safe_name}".') - - return redirect(url_for('edit_character', slug=slug)) - -@app.route('/character//outfit/delete', methods=['POST']) -def delete_outfit(slug): - """Delete an outfit from a character.""" - character = Character.query.filter_by(slug=slug).first_or_404() - outfit_name = request.form.get('outfit', '') - - wardrobe = character.data.get('wardrobe', {}) - - # Cannot delete default - if outfit_name == 'default': - flash('Cannot delete the default outfit.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - if outfit_name not in wardrobe: - flash(f'Outfit "{outfit_name}" not found.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - # Delete outfit - del wardrobe[outfit_name] - character.data['wardrobe'] = wardrobe - flag_modified(character, 'data') - - # Switch active outfit if deleted was active - if character.active_outfit == outfit_name: - character.active_outfit = 'default' - - # Save to JSON file - char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" - file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) - with open(file_path, 'w') as f: - json.dump(character.data, f, indent=2) - - db.session.commit() - flash(f'Deleted outfit "{outfit_name}".') - - return redirect(url_for('edit_character', slug=slug)) - -@app.route('/character//outfit/rename', methods=['POST']) -def rename_outfit(slug): - """Rename an outfit.""" - character = Character.query.filter_by(slug=slug).first_or_404() - old_name = request.form.get('old_name', '') - new_name = request.form.get('new_name', '').strip() - - if not new_name: - flash('New name cannot be empty.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - # Sanitize new name - safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', new_name.lower()) - - wardrobe = character.data.get('wardrobe', {}) - - if old_name not in wardrobe: - flash(f'Outfit "{old_name}" not found.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - if safe_name in wardrobe and safe_name != old_name: - flash(f'Outfit "{safe_name}" already exists.', 'error') - return redirect(url_for('edit_character', slug=slug)) - - # Rename (copy to new key, delete old) - wardrobe[safe_name] = wardrobe.pop(old_name) - character.data['wardrobe'] = wardrobe - flag_modified(character, 'data') - - # Update active outfit if renamed was active - if character.active_outfit == old_name: - character.active_outfit = safe_name - - # Save to JSON file - char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" - file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) - with open(file_path, 'w') as f: - json.dump(character.data, f, indent=2) - - db.session.commit() - flash(f'Renamed outfit "{old_name}" to "{safe_name}".') - - return redirect(url_for('edit_character', slug=slug)) - -@app.route('/character//upload', methods=['POST']) -def upload_image(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create character subfolder - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}") - os.makedirs(char_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(char_folder, filename) - file.save(file_path) - - # Store relative path in DB - character.image_path = f"characters/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('detail', slug=slug)) - -@app.route('/character//replace_cover_from_preview', methods=['POST']) -def replace_cover_from_preview(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - character.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('detail', slug=slug)) - -def _log_workflow_prompts(label, workflow): - """Log the final assembled ComfyUI prompts in a consistent, readable block.""" - sep = "=" * 72 - active_loras = [] - lora_details = [] - - # Collect detailed LoRA information - for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: - if node_id in workflow: - name = workflow[node_id]["inputs"].get("lora_name", "") - if name: - strength_model = workflow[node_id]["inputs"].get("strength_model", "?") - strength_clip = workflow[node_id]["inputs"].get("strength_clip", "?") - - # Short version for summary - if isinstance(strength_model, float): - active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model:.3f}") - else: - active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model}") - - # Detailed version - lora_details.append(f" Node {node_id} ({label_str}): {name}") - lora_details.append(f" strength_model={strength_model}, strength_clip={strength_clip}") - - # Extract VAE information - vae_info = "(integrated)" - if '21' in workflow: - vae_info = workflow['21']['inputs'].get('vae_name', '(custom)') - - # Extract adetailer information - adetailer_info = [] - for node_id, node_name in [("11", "Face"), ("13", "Hand")]: - if node_id in workflow: - adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, " - f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, " - f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}") - - face_text = workflow.get('14', {}).get('inputs', {}).get('text', '') - hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '') - - lines = [ - sep, - f" WORKFLOW PROMPTS [{label}]", - sep, - " MODEL CONFIGURATION:", - f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}", - f" VAE : {vae_info}", - "", - " GENERATION SETTINGS:", - f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}", - f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}", - f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')}", - f" Steps : {workflow['3']['inputs'].get('steps', '?')}", - f" CFG Scale : {workflow['3']['inputs'].get('cfg', '?')}", - f" Denoise : {workflow['3']['inputs'].get('denoise', '1.0')}", - ] - - # Add LoRA details - if active_loras: - lines.append("") - lines.append(" LORA CONFIGURATION:") - lines.extend(lora_details) - else: - lines.append("") - lines.append(" LORA CONFIGURATION: (none)") - - # Add adetailer details - if adetailer_info: - lines.append("") - lines.append(" ADETAILER CONFIGURATION:") - lines.extend(adetailer_info) - - # Add prompts - lines.extend([ - "", - " PROMPTS:", - f" [+] Positive : {workflow['6']['inputs'].get('text', '')}", - f" [-] Negative : {workflow['7']['inputs'].get('text', '')}", - ]) - - if face_text: - lines.append(f" [F] Face : {face_text}") - if hand_text: - lines.append(f" [H] Hand : {hand_text}") - - lines.append(sep) - logger.info("\n%s", "\n".join(lines)) - - -def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None): - # 1. Update prompts using replacement to preserve embeddings - workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"]) - - if custom_negative: - workflow["7"]["inputs"]["text"] = f"{workflow['7']['inputs']['text']}, {custom_negative}" - - if "14" in workflow: - workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"]) - if "15" in workflow: - workflow["15"]["inputs"]["text"] = workflow["15"]["inputs"]["text"].replace("{{HAND_PROMPT}}", prompts["hand"]) - - # 2. Update Checkpoint - always set one, fall back to default if not provided - if not checkpoint: - default_ckpt, default_ckpt_data = _get_default_checkpoint() - checkpoint = default_ckpt - if not checkpoint_data: - checkpoint_data = default_ckpt_data - if checkpoint: - workflow["4"]["inputs"]["ckpt_name"] = checkpoint - else: - raise ValueError("No checkpoint specified and no default checkpoint configured") - - # 3. Handle LoRAs - Node 16 for character, Node 17 for outfit, Node 18 for action, Node 19 for style/detailer - # Start with direct checkpoint connections - model_source = ["4", 0] - clip_source = ["4", 1] - - # Look negative prompt (applied before character LoRA) - if look: - look_negative = look.data.get('negative', '') - if look_negative: - workflow["7"]["inputs"]["text"] = f"{look_negative}, {workflow['7']['inputs']['text']}" - - # Character LoRA (Node 16) — look LoRA overrides character LoRA when present - if look: - char_lora_data = look.data.get('lora', {}) - else: - char_lora_data = character.data.get('lora', {}) if character else {} - char_lora_name = char_lora_data.get('lora_name') - - if char_lora_name and "16" in workflow: - _w16 = _resolve_lora_weight(char_lora_data) - workflow["16"]["inputs"]["lora_name"] = char_lora_name - workflow["16"]["inputs"]["strength_model"] = _w16 - workflow["16"]["inputs"]["strength_clip"] = _w16 - workflow["16"]["inputs"]["model"] = ["4", 0] # From checkpoint - workflow["16"]["inputs"]["clip"] = ["4", 1] # From checkpoint - model_source = ["16", 0] - clip_source = ["16", 1] - logger.debug("Character LoRA: %s @ %s", char_lora_name, _w16) - - # Outfit LoRA (Node 17) - chains from character LoRA or checkpoint - outfit_lora_data = outfit.data.get('lora', {}) if outfit else {} - outfit_lora_name = outfit_lora_data.get('lora_name') - - if outfit_lora_name and "17" in workflow: - _w17 = _resolve_lora_weight({**{'lora_weight': 0.8}, **outfit_lora_data}) - workflow["17"]["inputs"]["lora_name"] = outfit_lora_name - workflow["17"]["inputs"]["strength_model"] = _w17 - workflow["17"]["inputs"]["strength_clip"] = _w17 - # Chain from character LoRA (node 16) or checkpoint (node 4) - workflow["17"]["inputs"]["model"] = model_source - workflow["17"]["inputs"]["clip"] = clip_source - model_source = ["17", 0] - clip_source = ["17", 1] - logger.debug("Outfit LoRA: %s @ %s", outfit_lora_name, _w17) - - # Action LoRA (Node 18) - chains from previous LoRA or checkpoint - action_lora_data = action.data.get('lora', {}) if action else {} - action_lora_name = action_lora_data.get('lora_name') - - if action_lora_name and "18" in workflow: - _w18 = _resolve_lora_weight(action_lora_data) - workflow["18"]["inputs"]["lora_name"] = action_lora_name - workflow["18"]["inputs"]["strength_model"] = _w18 - workflow["18"]["inputs"]["strength_clip"] = _w18 - # Chain from previous source - workflow["18"]["inputs"]["model"] = model_source - workflow["18"]["inputs"]["clip"] = clip_source - model_source = ["18", 0] - clip_source = ["18", 1] - logger.debug("Action LoRA: %s @ %s", action_lora_name, _w18) - - # Style/Detailer/Scene LoRA (Node 19) - chains from previous LoRA or checkpoint - # Priority: Style > Detailer > Scene (Scene LoRAs are rare but supported) - target_obj = style or detailer or scene - style_lora_data = target_obj.data.get('lora', {}) if target_obj else {} - style_lora_name = style_lora_data.get('lora_name') - - if style_lora_name and "19" in workflow: - _w19 = _resolve_lora_weight(style_lora_data) - workflow["19"]["inputs"]["lora_name"] = style_lora_name - workflow["19"]["inputs"]["strength_model"] = _w19 - workflow["19"]["inputs"]["strength_clip"] = _w19 - # Chain from previous source - workflow["19"]["inputs"]["model"] = model_source - workflow["19"]["inputs"]["clip"] = clip_source - model_source = ["19", 0] - clip_source = ["19", 1] - logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19) - - # Apply connections to all model/clip consumers - workflow["3"]["inputs"]["model"] = model_source - workflow["11"]["inputs"]["model"] = model_source - workflow["13"]["inputs"]["model"] = model_source - - workflow["6"]["inputs"]["clip"] = clip_source - workflow["7"]["inputs"]["clip"] = clip_source - workflow["11"]["inputs"]["clip"] = clip_source - workflow["13"]["inputs"]["clip"] = clip_source - workflow["14"]["inputs"]["clip"] = clip_source - workflow["15"]["inputs"]["clip"] = clip_source - - # 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery) - gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15) - workflow["3"]["inputs"]["seed"] = gen_seed - if "11" in workflow: workflow["11"]["inputs"]["seed"] = gen_seed - if "13" in workflow: workflow["13"]["inputs"]["seed"] = gen_seed - - # 5. Set image dimensions - if "5" in workflow: - if width: - workflow["5"]["inputs"]["width"] = int(width) - if height: - workflow["5"]["inputs"]["height"] = int(height) - - # 6. Apply checkpoint-specific settings (steps, cfg, sampler, base prompts, VAE) - if checkpoint_data: - workflow = _apply_checkpoint_settings(workflow, checkpoint_data) - - # 7. Sync sampler/scheduler from main KSampler to adetailer nodes - sampler_name = workflow["3"]["inputs"].get("sampler_name") - scheduler = workflow["3"]["inputs"].get("scheduler") - for node_id in ["11", "13"]: - if node_id in workflow: - if sampler_name: - workflow[node_id]["inputs"]["sampler_name"] = sampler_name - if scheduler: - workflow[node_id]["inputs"]["scheduler"] = scheduler - - # 8. Cross-deduplicate: remove tags shared between positive and negative - pos_text, neg_text = _cross_dedup_prompts( - workflow["6"]["inputs"]["text"], - workflow["7"]["inputs"]["text"] - ) - workflow["6"]["inputs"]["text"] = pos_text - workflow["7"]["inputs"]["text"] = neg_text - - # 9. Final prompt debug — logged after all transformations are complete - _log_workflow_prompts("_prepare_workflow", workflow) - - return workflow - -def _get_default_checkpoint(): - """Return (checkpoint_path, checkpoint_data) from the database Settings, session, or fall back to workflow file.""" - ckpt_path = session.get('default_checkpoint') - - # If no session checkpoint, try to read from database Settings - if not ckpt_path: - settings = Settings.query.first() - if settings and settings.default_checkpoint: - ckpt_path = settings.default_checkpoint - logger.debug("Loaded default checkpoint from database: %s", ckpt_path) - - # If still no checkpoint, try to read from the workflow file - if not ckpt_path: - try: - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - ckpt_path = workflow.get('4', {}).get('inputs', {}).get('ckpt_name') - logger.debug("Loaded default checkpoint from workflow file: %s", ckpt_path) - except Exception: - pass - - if not ckpt_path: - return None, None - - ckpt = Checkpoint.query.filter_by(checkpoint_path=ckpt_path).first() - if not ckpt: - # Checkpoint path exists but not in DB - return path with empty data - return ckpt_path, {} - return ckpt.checkpoint_path, ckpt.data or {} - -@app.route('/get_missing_characters') -def get_missing_characters(): - missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all() - return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} - -@app.route('/clear_all_covers', methods=['POST']) -def clear_all_covers(): - characters = Character.query.all() - for char in characters: - char.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/generate_missing', methods=['POST']) -def generate_missing(): - missing = Character.query.filter( - (Character.image_path == None) | (Character.image_path == '') - ).order_by(Character.name).all() - - if not missing: - flash("No characters missing cover images.") - return redirect(url_for('index')) - - enqueued = 0 - for character in missing: - try: - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - prompts = build_prompt(character.data, None, character.default_fields, character.active_outfit) - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - _slug = character.slug - _enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character)) - enqueued += 1 - except Exception as e: - print(f"Error queuing cover generation for {character.name}: {e}") - - flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.") - return redirect(url_for('index')) - -@app.route('/character//generate', methods=['POST']) -def generate_image(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Save preferences - session[f'prefs_{slug}'] = selected_fields - session.modified = True - - # Build workflow - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit) - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - label = f"{character.name} – {action}" - job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action)) - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - return redirect(url_for('detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('detail', slug=slug)) - -@app.route('/character//save_defaults', methods=['POST']) -def save_defaults(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - character.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this character!') - return redirect(url_for('detail', slug=slug)) - -@app.route('/get_missing_outfits') -def get_missing_outfits(): - missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all() - return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]} - -@app.route('/clear_all_outfit_covers', methods=['POST']) -def clear_all_outfit_covers(): - outfits = Outfit.query.all() - for outfit in outfits: - outfit.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/get_missing_actions') -def get_missing_actions(): - missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all() - return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]} - -@app.route('/clear_all_action_covers', methods=['POST']) -def clear_all_action_covers(): - actions = Action.query.all() - for action in actions: - action.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/get_missing_scenes') -def get_missing_scenes(): - missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all() - return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} - -@app.route('/clear_all_scene_covers', methods=['POST']) -def clear_all_scene_covers(): - scenes = Scene.query.all() - for scene in scenes: - scene.image_path = None - db.session.commit() - return {'success': True} - -# ============ PRESET ROUTES ============ - -@app.route('/presets') -def presets_index(): - presets = Preset.query.order_by(Preset.filename).all() - return render_template('presets/index.html', presets=presets) - - -@app.route('/preset/') -def preset_detail(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - preview_path = session.get(f'preview_preset_{slug}') - return render_template('presets/detail.html', preset=preset, preview_path=preview_path) - - -@app.route('/preset//generate', methods=['POST']) -def generate_preset_image(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - - try: - action = request.form.get('action', 'preview') - data = preset.data - - # Resolve entities - char_cfg = data.get('character', {}) - character = _resolve_preset_entity('character', char_cfg.get('character_id')) - if not character: - character = Character.query.order_by(db.func.random()).first() - - outfit_cfg = data.get('outfit', {}) - action_cfg = data.get('action', {}) - style_cfg = data.get('style', {}) - scene_cfg = data.get('scene', {}) - detailer_cfg = data.get('detailer', {}) - look_cfg = data.get('look', {}) - ckpt_cfg = data.get('checkpoint', {}) - - outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id')) - action_obj = _resolve_preset_entity('action', action_cfg.get('action_id')) - style_obj = _resolve_preset_entity('style', style_cfg.get('style_id')) - scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id')) - detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id')) - look_obj = _resolve_preset_entity('look', look_cfg.get('look_id')) - - # Checkpoint: preset override or session default - preset_ckpt = ckpt_cfg.get('checkpoint_path') - if preset_ckpt == 'random': - ckpt_obj = Checkpoint.query.order_by(db.func.random()).first() - ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None - ckpt_data = ckpt_obj.data if ckpt_obj else None - elif preset_ckpt: - ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first() - ckpt_path = preset_ckpt - ckpt_data = ckpt_obj.data if ckpt_obj else None - else: - ckpt_path, ckpt_data = _get_default_checkpoint() - - # Resolve selected fields from preset toggles - selected_fields = _resolve_preset_fields(data) - - # 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 {} - - combined_data = { - 'character_id': character.character_id if character else 'unknown', - 'identity': character.data.get('identity', {}) if character else {}, - 'defaults': character.data.get('defaults', {}) if character else {}, - 'wardrobe': wardrobe_source, - '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 - extras_parts = [] - if action_obj: - action_fields = action_cfg.get('fields', {}) - for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']: - val_cfg = action_fields.get(key, True) - if val_cfg == 'random': - val_cfg = random.choice([True, False]) - if val_cfg: - val = action_obj.data.get('action', {}).get(key, '') - if val: - extras_parts.append(val) - if action_cfg.get('use_lora', True): - 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'): - extras_parts.append(f"by {s['artist_name']}") - if s.get('artistic_style'): - extras_parts.append(s['artistic_style']) - if style_cfg.get('use_lora', True): - trg = style_obj.data.get('lora', {}).get('lora_triggers', '') - if trg: - extras_parts.append(trg) - if scene_obj: - scene_fields = scene_cfg.get('fields', {}) - for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: - val_cfg = scene_fields.get(key, True) - if val_cfg == 'random': - val_cfg = random.choice([True, False]) - if val_cfg: - val = scene_obj.data.get('scene', {}).get(key, '') - if val: - extras_parts.append(val) - if scene_cfg.get('use_lora', True): - 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): - extras_parts.extend(p for p in prompt_val if p) - elif prompt_val: - extras_parts.append(prompt_val) - if detailer_cfg.get('use_lora', True): - trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '') - if trg: - extras_parts.append(trg) - - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - prompts = build_prompt(combined_data, selected_fields, default_fields=None, - active_outfit=active_wardrobe) - if extras_parts: - extra_str = ', '.join(filter(None, extras_parts)) - prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str) - - workflow = _prepare_workflow( - workflow, character, prompts, - checkpoint=ckpt_path, checkpoint_data=ckpt_data, - outfit=outfit if outfit_cfg.get('use_lora', True) else None, - action=action_obj if action_cfg.get('use_lora', True) else None, - style=style_obj if style_cfg.get('use_lora', True) else None, - scene=scene_obj if scene_cfg.get('use_lora', True) else None, - detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None, - look=look_obj, - ) - - label = f"Preset: {preset.name} – {action}" - job = _enqueue_job(label, workflow, _make_finalize('presets', slug, Preset, action)) - - session[f'preview_preset_{slug}'] = None - session.modified = True - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - return redirect(url_for('preset_detail', slug=slug)) - - except Exception as e: - logger.exception("Generation error (preset %s): %s", slug, e) - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('preset_detail', slug=slug)) - - -@app.route('/preset//replace_cover_from_preview', methods=['POST']) -def replace_preset_cover_from_preview(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - preset.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('preset_detail', slug=slug)) - - -@app.route('/preset//upload', methods=['POST']) -def upload_preset_image(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file uploaded.') - return redirect(url_for('preset_detail', slug=slug)) - file = request.files['image'] - if file.filename == '': - flash('No file selected.') - return redirect(url_for('preset_detail', slug=slug)) - filename = secure_filename(file.filename) - folder = os.path.join(app.config['UPLOAD_FOLDER'], f'presets/{slug}') - os.makedirs(folder, exist_ok=True) - file.save(os.path.join(folder, filename)) - preset.image_path = f'presets/{slug}/{filename}' - db.session.commit() - flash('Image uploaded!') - return redirect(url_for('preset_detail', slug=slug)) - - -@app.route('/preset//edit', methods=['GET', 'POST']) -def edit_preset(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - if request.method == 'POST': - name = request.form.get('preset_name', preset.name) - preset.name = name - - # Rebuild the data dict from form fields - def _tog(val): - """Convert form value ('true'/'false'/'random') to JSON toggle value.""" - if val == 'random': - return 'random' - return val == 'true' - - def _entity_id(val): - return val if val else None - - char_id = _entity_id(request.form.get('char_character_id')) - new_data = { - 'preset_id': preset.preset_id, - 'preset_name': name, - 'character': { - 'character_id': char_id, - 'use_lora': request.form.get('char_use_lora') == 'on', - 'fields': { - 'identity': {k: _tog(request.form.get(f'id_{k}', 'true')) - for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, - 'defaults': {k: _tog(request.form.get(f'def_{k}', 'false')) - for k in ['expression', 'pose', 'scene']}, - 'wardrobe': { - 'outfit': request.form.get('wardrobe_outfit', 'default') or 'default', - 'fields': {k: _tog(request.form.get(f'wd_{k}', 'true')) - for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}, - }, - }, - }, - 'outfit': {'outfit_id': _entity_id(request.form.get('outfit_id')), - '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', - 'fields': {k: _tog(request.form.get(f'act_{k}', 'true')) - for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, - 'style': {'style_id': _entity_id(request.form.get('style_id')), - 'use_lora': request.form.get('style_use_lora') == 'on'}, - 'scene': {'scene_id': _entity_id(request.form.get('scene_id')), - 'use_lora': request.form.get('scene_use_lora') == 'on', - 'fields': {k: _tog(request.form.get(f'scn_{k}', 'true')) - for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}}, - 'detailer': {'detailer_id': _entity_id(request.form.get('detailer_id')), - 'use_lora': request.form.get('detailer_use_lora') == 'on'}, - 'look': {'look_id': _entity_id(request.form.get('look_id'))}, - 'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))}, - 'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()], - } - - preset.data = new_data - flag_modified(preset, "data") - db.session.commit() - - # Write back to JSON file - if preset.filename: - file_path = os.path.join(app.config['PRESETS_DIR'], preset.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - flash('Preset saved!') - return redirect(url_for('preset_detail', slug=slug)) - - characters = Character.query.order_by(Character.name).all() - outfits = Outfit.query.order_by(Outfit.name).all() - actions = Action.query.order_by(Action.name).all() - styles = Style.query.order_by(Style.name).all() - scenes = Scene.query.order_by(Scene.name).all() - detailers = Detailer.query.order_by(Detailer.name).all() - looks = Look.query.order_by(Look.name).all() - checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() - return render_template('presets/edit.html', preset=preset, - characters=characters, outfits=outfits, actions=actions, - styles=styles, scenes=scenes, detailers=detailers, - looks=looks, checkpoints=checkpoints) - - -@app.route('/preset//save_json', methods=['POST']) -def save_preset_json(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - preset.data = new_data - preset.name = new_data.get('preset_name', preset.name) - flag_modified(preset, "data") - db.session.commit() - if preset.filename: - file_path = os.path.join(app.config['PRESETS_DIR'], preset.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - except Exception as e: - return {'success': False, 'error': str(e)}, 400 - - -@app.route('/preset//clone', methods=['POST']) -def clone_preset(slug): - original = Preset.query.filter_by(slug=slug).first_or_404() - new_data = dict(original.data) - - base_id = f"{original.preset_id}_copy" - new_id = base_id - counter = 1 - while Preset.query.filter_by(preset_id=new_id).first(): - new_id = f"{base_id}_{counter}" - counter += 1 - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_data['preset_id'] = new_id - new_data['preset_name'] = f"{original.name} (Copy)" - new_filename = f"{new_id}.json" - - os.makedirs(app.config['PRESETS_DIR'], exist_ok=True) - with open(os.path.join(app.config['PRESETS_DIR'], new_filename), 'w') as f: - json.dump(new_data, f, indent=2) - - new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['preset_name'], data=new_data) - db.session.add(new_preset) - db.session.commit() - flash(f"Cloned as '{new_data['preset_name']}'") - return redirect(url_for('preset_detail', slug=new_slug)) - - -@app.route('/presets/rescan', methods=['POST']) -def rescan_presets(): - sync_presets() - flash('Preset library synced.') - return redirect(url_for('presets_index')) - - -@app.route('/preset/create', methods=['GET', 'POST']) -def create_preset(): - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - use_llm = request.form.get('use_llm') == 'on' - - 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 - counter = 1 - while os.path.exists(os.path.join(app.config['PRESETS_DIR'], f"{safe_id}.json")): - safe_id = f"{base_id}_{counter}" - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id) - counter += 1 - - if use_llm and description: - system_prompt = load_prompt('preset_system.txt') - if not system_prompt: - flash('Preset system prompt file not found.', 'error') - return redirect(request.url) - try: - llm_response = call_llm( - f"Create a preset profile named '{name}' based on this description: {description}", - system_prompt - ) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - preset_data = json.loads(clean_json) - except Exception as e: - logger.exception("LLM error creating preset: %s", e) - flash(f"AI generation failed: {e}", 'error') - return redirect(request.url) - else: - # Blank preset with sensible defaults - preset_data = { - 'character': {'character_id': 'random', 'use_lora': True, - 'fields': { - 'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, - 'defaults': {k: False for k in ['expression', 'pose', 'scene']}, - 'wardrobe': {'outfit': 'default', - 'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}}, - }}, - 'outfit': {'outfit_id': None, 'use_lora': True}, - 'action': {'action_id': None, 'use_lora': True, - 'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, - 'style': {'style_id': None, 'use_lora': True}, - 'scene': {'scene_id': None, 'use_lora': True, - 'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}}, - 'detailer': {'detailer_id': None, 'use_lora': True}, - 'look': {'look_id': None}, - 'checkpoint': {'checkpoint_path': None}, - 'tags': [], - } - - preset_data['preset_id'] = safe_id - preset_data['preset_name'] = name - - os.makedirs(app.config['PRESETS_DIR'], exist_ok=True) - file_path = os.path.join(app.config['PRESETS_DIR'], f"{safe_id}.json") - with open(file_path, 'w') as f: - json.dump(preset_data, f, indent=2) - - new_preset = Preset(preset_id=safe_id, slug=safe_slug, - filename=f"{safe_id}.json", name=name, data=preset_data) - db.session.add(new_preset) - db.session.commit() - flash(f"Preset '{name}' created!") - return redirect(url_for('edit_preset', slug=safe_slug)) - - return render_template('presets/create.html') - - -@app.route('/get_missing_presets') -def get_missing_presets(): - missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all() - return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]} - - -# ============ OUTFIT ROUTES ============ - -@app.route('/outfits') -def outfits_index(): - outfits = Outfit.query.order_by(Outfit.name).all() - lora_assignments = _count_outfit_lora_assignments() - return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments) - -@app.route('/outfits/rescan', methods=['POST']) -def rescan_outfits(): - sync_outfits() - flash('Database synced with outfit files.') - return redirect(url_for('outfits_index')) - -@app.route('/outfits/bulk_create', methods=['POST']) -def bulk_create_outfits_from_loras(): - _s = Settings.query.first() - 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): - 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: - flash('Outfit system prompt file not found.', 'error') - return redirect(url_for('outfits_index')) - - for filename in os.listdir(clothing_lora_dir): - if not filename.endswith('.safetensors'): - continue - - name_base = filename.rsplit('.', 1)[0] - outfit_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) - outfit_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() - - json_filename = f"{outfit_id}.json" - json_path = os.path.join(app.config['CLOTHING_DIR'], json_filename) - - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - continue - - html_filename = f"{name_base}.html" - html_path = os.path.join(clothing_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}") - - 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###" - - llm_response = call_llm(prompt, system_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 - - 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 - - os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True) - with open(json_path, 'w') as f: - json.dump(outfit_data, f, indent=2) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - time.sleep(0.5) - - except Exception as e: - print(f"Error creating outfit for {filename}: {e}") - - 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.') - - return redirect(url_for('outfits_index')) - -@app.route('/outfit/') -def outfit_detail(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - - # Load state from session - preferences = session.get(f'prefs_outfit_{slug}') - preview_image = session.get(f'preview_outfit_{slug}') - selected_character = session.get(f'char_outfit_{slug}') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"outfits/{slug}/{f}" for f in files] - - return render_template('outfits/detail.html', outfit=outfit, characters=characters, - preferences=preferences, preview_image=preview_image, - selected_character=selected_character, existing_previews=existing_previews) - -@app.route('/outfit//edit', methods=['GET', 'POST']) -def edit_outfit(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('outfits') # Use clothing LoRAs for outfits - - if request.method == 'POST': - try: - # 1. Update basic fields - outfit.name = request.form.get('outfit_name') - - # 2. Rebuild the data dictionary - new_data = outfit.data.copy() - new_data['outfit_name'] = outfit.name - - # Update outfit_id if provided - new_outfit_id = request.form.get('outfit_id', outfit.outfit_id) - new_data['outfit_id'] = new_outfit_id - - # Update wardrobe section - if 'wardrobe' in new_data: - for key in new_data['wardrobe'].keys(): - form_key = f"wardrobe_{key}" - if form_key in request.form: - new_data['wardrobe'][key] = request.form.get(form_key) - - # Update lora section - if 'lora' in new_data: - for key in new_data['lora'].keys(): - form_key = f"lora_{key}" - if form_key in request.form: - val = request.form.get(form_key) - if key == 'lora_weight': - try: val = float(val) - except: val = 0.8 - new_data['lora'][key] = val - - # LoRA weight randomization bounds - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - 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] - - outfit.data = new_data - flag_modified(outfit, "data") - - # 3. Write back to JSON file - outfit_file = outfit.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', outfit.outfit_id)}.json" - file_path = os.path.join(app.config['CLOTHING_DIR'], outfit_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Outfit profile updated successfully!') - return redirect(url_for('outfit_detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('outfits/edit.html', outfit=outfit, loras=loras) - -@app.route('/outfit//upload', methods=['POST']) -def upload_outfit_image(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create outfit subfolder - outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") - os.makedirs(outfit_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(outfit_folder, filename) - file.save(file_path) - - # Store relative path in DB - outfit.image_path = f"outfits/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('outfit_detail', slug=slug)) - -@app.route('/outfit//generate', methods=['POST']) -def generate_outfit_image(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Get selected character (if any) - character_slug = request.form.get('character_slug', '') - character = None - - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - # Save preferences - session[f'prefs_outfit_{slug}'] = selected_fields - session[f'char_outfit_{slug}'] = character_slug - session.modified = True - - # Build combined data for prompt building - if character: - # Combine character identity/defaults with outfit wardrobe - combined_data = { - 'character_id': character.character_id, - 'identity': character.data.get('identity', {}), - 'defaults': character.data.get('defaults', {}), - 'wardrobe': outfit.data.get('wardrobe', {}), # Use outfit's wardrobe - 'styles': character.data.get('styles', {}), # Use character's styles - 'lora': outfit.data.get('lora', {}), # Use outfit's lora - 'tags': outfit.data.get('tags', []) - } - - # Merge character identity/defaults into selected_fields so they appear in the prompt - if selected_fields: - _ensure_character_fields(character, selected_fields, - include_wardrobe=False, include_defaults=True) - else: - # No explicit field selection (e.g. batch generation) — build a selection - # that includes identity + wardrobe + name + lora triggers, but NOT character - # defaults (expression, pose, scene), so outfit covers stay generic. - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - outfit_wardrobe = outfit.data.get('wardrobe', {}) - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if outfit_wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - selected_fields.append('special::name') - if outfit.data.get('lora', {}).get('lora_triggers'): - selected_fields.append('lora::lora_triggers') - - default_fields = character.default_fields - else: - # Outfit only - no character - combined_data = { - 'character_id': outfit.outfit_id, - 'wardrobe': outfit.data.get('wardrobe', {}), - 'lora': outfit.data.get('lora', {}), - 'tags': outfit.data.get('tags', []) - } - default_fields = outfit.default_fields - - # Queue generation - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - # Build prompts for combined data - prompts = build_prompt(combined_data, selected_fields, default_fields) - - _append_background(prompts, character) - - # Prepare workflow - pass both character and outfit for dual LoRA support - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - char_label = character.name if character else 'no character' - label = f"Outfit: {outfit.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('outfits', slug, Outfit, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - return redirect(url_for('outfit_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('outfit_detail', slug=slug)) - -@app.route('/outfit//replace_cover_from_preview', methods=['POST']) -def replace_outfit_cover_from_preview(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - outfit.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('outfit_detail', slug=slug)) - -@app.route('/outfit/create', methods=['GET', 'POST']) -def create_outfit(): - 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' - - # Auto-generate slug from name if not provided - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - # Validate slug - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'outfit' - - # Find available filename (increment if exists) - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - # Check if LLM generation is requested - if use_llm: - if not prompt: - flash("Description is required when AI generation is enabled.") - return redirect(request.url) - - # 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) - - try: - llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt) - - # Clean response (remove markdown if present) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - outfit_data = json.loads(clean_json) - - # Enforce IDs - outfit_data['outfit_id'] = safe_slug - outfit_data['outfit_name'] = name - - # Ensure required fields exist - if 'wardrobe' not in outfit_data: - outfit_data['wardrobe'] = { - "full_body": "", - "headwear": "", - "top": "", - "bottom": "", - "legwear": "", - "footwear": "", - "hands": "", - "accessories": "" - } - if 'lora' not in outfit_data: - outfit_data['lora'] = { - "lora_name": "", - "lora_weight": 0.8, - "lora_triggers": "" - } - if 'tags' not in outfit_data: - outfit_data['tags'] = [] - - except Exception as e: - print(f"LLM error: {e}") - flash(f"Failed to generate outfit profile: {e}") - return redirect(request.url) - else: - # Create blank outfit template - outfit_data = { - "outfit_id": safe_slug, - "outfit_name": name, - "wardrobe": { - "full_body": "", - "headwear": "", - "top": "", - "bottom": "", - "legwear": "", - "footwear": "", - "hands": "", - "accessories": "" - }, - "lora": { - "lora_name": "", - "lora_weight": 0.8, - "lora_triggers": "" - }, - "tags": [] - } - - try: - # Save file - file_path = os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(outfit_data, f, indent=2) - - # Add to DB - new_outfit = Outfit( - outfit_id=safe_slug, - slug=safe_slug, - filename=f"{safe_slug}.json", - name=name, - data=outfit_data - ) - db.session.add(new_outfit) - db.session.commit() - - flash('Outfit created successfully!') - return redirect(url_for('outfit_detail', slug=safe_slug)) - - 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') - -@app.route('/outfit//save_defaults', methods=['POST']) -def save_outfit_defaults(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - outfit.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this outfit!') - return redirect(url_for('outfit_detail', slug=slug)) - -@app.route('/outfit//clone', methods=['POST']) -def clone_outfit(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - - # Find the next available number for the clone - base_id = outfit.outfit_id - # Extract base name without number suffix - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - # Find next available number - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['CLOTHING_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - # Create new outfit data (copy of original) - new_data = outfit.data.copy() - new_data['outfit_id'] = new_id - new_data['outfit_name'] = f"{outfit.name} (Copy)" - - # Save the new JSON file - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - # Create new outfit in database - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_outfit = Outfit( - outfit_id=new_id, - slug=new_slug, - filename=new_filename, - name=new_data['outfit_name'], - data=new_data - ) - db.session.add(new_outfit) - db.session.commit() - - flash(f'Outfit cloned as "{new_id}"!') - return redirect(url_for('outfit_detail', slug=new_slug)) - -@app.route('/outfit//save_json', methods=['POST']) -def save_outfit_json(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - outfit.data = new_data - flag_modified(outfit, 'data') - db.session.commit() - if outfit.filename: - file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -# ============ ACTION ROUTES ============ - -@app.route('/actions') -def actions_index(): - actions = Action.query.order_by(Action.name).all() - return render_template('actions/index.html', actions=actions) - -@app.route('/actions/rescan', methods=['POST']) -def rescan_actions(): - sync_actions() - flash('Database synced with action files.') - return redirect(url_for('actions_index')) - -@app.route('/action/') -def action_detail(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - - # Load state from session - preferences = session.get(f'prefs_action_{slug}') - preview_image = session.get(f'preview_action_{slug}') - selected_character = session.get(f'char_action_{slug}') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"actions/{slug}/{f}" for f in files] - - return render_template('actions/detail.html', action=action, characters=characters, - preferences=preferences, preview_image=preview_image, - selected_character=selected_character, existing_previews=existing_previews) - -@app.route('/action//edit', methods=['GET', 'POST']) -def edit_action(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('actions') - - if request.method == 'POST': - try: - # 1. Update basic fields - action.name = request.form.get('action_name') - - # 2. Rebuild the data dictionary - new_data = action.data.copy() - new_data['action_name'] = action.name - - # Update action_id if provided - new_action_id = request.form.get('action_id', action.action_id) - new_data['action_id'] = new_action_id - - # Update action section - if 'action' in new_data: - for key in new_data['action'].keys(): - form_key = f"action_{key}" - if form_key in request.form: - new_data['action'][key] = request.form.get(form_key) - - # Update lora section - if 'lora' in new_data: - for key in new_data['lora'].keys(): - form_key = f"lora_{key}" - if form_key in request.form: - val = request.form.get(form_key) - if key == 'lora_weight': - try: val = float(val) - except: val = 1.0 - new_data['lora'][key] = val - - # LoRA weight randomization bounds - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - 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] - - action.data = new_data - flag_modified(action, "data") - - # 3. Write back to JSON file - action_file = action.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', action.action_id)}.json" - file_path = os.path.join(app.config['ACTIONS_DIR'], action_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Action profile updated successfully!') - return redirect(url_for('action_detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('actions/edit.html', action=action, loras=loras) - -@app.route('/action//upload', methods=['POST']) -def upload_action_image(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create action subfolder - action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") - os.makedirs(action_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(action_folder, filename) - file.save(file_path) - - # Store relative path in DB - action.image_path = f"actions/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('action_detail', slug=slug)) - -@app.route('/action//generate', methods=['POST']) -def generate_action_image(slug): - action_obj = Action.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Get selected character (if any) - character_slug = request.form.get('character_slug', '') - character = None - - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - # Save preferences - session[f'char_action_{slug}'] = character_slug - session[f'prefs_action_{slug}'] = selected_fields - session.modified = True - - # Build combined data for prompt building - if character: - # Combine character identity/wardrobe with action details - # Action details replace character's 'defaults' (pose, etc.) - combined_data = character.data.copy() - - # Update 'defaults' with action details - action_data = action_obj.data.get('action', {}) - combined_data['action'] = action_data # Ensure action section is present for routing - combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants - - # Aggregate pose-related fields into 'pose' - pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] - pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] - - # Aggregate expression-related fields into 'expression' - expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] - - combined_data['defaults'] = { - 'pose': ", ".join(pose_parts), - 'expression': ", ".join(expression_parts), - 'scene': action_data.get('additional', '') - } - - # Merge lora triggers if present - action_lora = action_obj.data.get('lora', {}) - if action_lora.get('lora_triggers'): - if 'lora' not in combined_data: combined_data['lora'] = {} - combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}" - - # Merge tags - combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', []))) - - # Use action's defaults if no manual selection - if not selected_fields: - selected_fields = list(action_obj.default_fields) if action_obj.default_fields else [] - - # Auto-include essential character fields if a character is selected - if selected_fields: - _ensure_character_fields(character, selected_fields) - else: - # Fallback to sensible defaults if still empty (no checkboxes and no action defaults) - selected_fields = ['special::name', 'defaults::pose', 'defaults::expression'] - # Add identity fields - for key in ['base_specs', 'hair', 'eyes']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - # Add wardrobe fields - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - - default_fields = action_obj.default_fields - active_outfit = character.active_outfit - else: - # Action only - no character (rarely makes sense for actions but let's handle it) - action_data = action_obj.data.get('action', {}) - - # Aggregate pose-related fields into 'pose' - pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] - pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] - - # Aggregate expression-related fields into 'expression' - expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] - - combined_data = { - 'character_id': action_obj.action_id, - 'defaults': { - 'pose': ", ".join(pose_parts), - 'expression': ", ".join(expression_parts), - 'scene': action_data.get('additional', '') - }, - 'lora': action_obj.data.get('lora', {}), - 'tags': action_obj.data.get('tags', []) - } - if not selected_fields: - selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags'] - default_fields = action_obj.default_fields - active_outfit = 'default' - - # Queue generation - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - # Build prompts for combined data - prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - - # Handle multiple female characters - participants = action_obj.data.get('participants', {}) - orientation = participants.get('orientation', '') - f_count = orientation.upper().count('F') - - if f_count > 1: - # We need f_count - 1 additional characters - num_extras = f_count - 1 - - # Get all characters excluding the current one - query = Character.query - if character: - query = query.filter(Character.id != character.id) - all_others = query.all() - - if len(all_others) >= num_extras: - extras = random.sample(all_others, num_extras) - - for extra_char in extras: - extra_parts = [] - - # Identity - ident = extra_char.data.get('identity', {}) - for key in ['base_specs', 'hair', 'eyes', 'extra']: - val = ident.get(key) - if val: - # Remove 1girl/solo - val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ') - extra_parts.append(val) - - # Wardrobe (active outfit) - wardrobe = extra_char.get_active_wardrobe() - for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']: - val = wardrobe.get(key) - if val: - extra_parts.append(val) - - # Append to main prompt - if extra_parts: - prompts["main"] += ", " + ", ".join(extra_parts) - print(f"Added extra character: {extra_char.name}") - - _append_background(prompts, character) - - # Prepare workflow - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - - char_label = character.name if character else 'no character' - label = f"Action: {action_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - return redirect(url_for('action_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('action_detail', slug=slug)) - -@app.route('/action//replace_cover_from_preview', methods=['POST']) -def replace_action_cover_from_preview(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - action.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('action_detail', slug=slug)) - -@app.route('/action//save_defaults', methods=['POST']) -def save_action_defaults(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - action.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this action!') - return redirect(url_for('action_detail', slug=slug)) - -@app.route('/actions/bulk_create', methods=['POST']) -def bulk_create_actions_from_loras(): - _s = Settings.query.first() - actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/') - _lora_subfolder = os.path.basename(actions_lora_dir) - if not os.path.exists(actions_lora_dir): - flash('Actions LoRA directory not found.', 'error') - return redirect(url_for('actions_index')) - - overwrite = request.form.get('overwrite') == 'true' - created_count = 0 - skipped_count = 0 - overwritten_count = 0 - - system_prompt = load_prompt('action_system.txt') - if not system_prompt: - flash('Action system prompt file not found.', 'error') - return redirect(url_for('actions_index')) - - for filename in os.listdir(actions_lora_dir): - if filename.endswith('.safetensors'): - name_base = filename.rsplit('.', 1)[0] - action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) - action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() - - 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_count += 1 - continue - - html_filename = f"{name_base}.html" - html_path = os.path.join(actions_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() - # 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}") - - 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###" - - llm_response = call_llm(prompt, system_prompt) - - # Clean response - 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 - - # Update lora dict safely - if 'lora' not in action_data: action_data['lora'] = {} - action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" - - # Fallbacks if LLM failed to extract metadata - if not action_data['lora'].get('lora_triggers'): - action_data['lora']['lora_triggers'] = name_base - 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: - action_data['lora']['lora_weight_min'] = 0.7 - 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: - json.dump(action_data, f, indent=2) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - # Small delay to avoid API rate limits if many files - time.sleep(0.5) - - except Exception as e: - print(f"Error creating action for {filename}: {e}") - - 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.') - - return redirect(url_for('actions_index')) - -@app.route('/action/create', methods=['GET', 'POST']) -def create_action(): - 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' - - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'action' - - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - if use_llm: - if not prompt: - flash("Description is required when AI generation is enabled.") - return redirect(request.url) - - system_prompt = load_prompt('action_system.txt') - if not system_prompt: - flash("Action system prompt file not found.") - return redirect(request.url) - - try: - llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt) - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - action_data = json.loads(clean_json) - action_data['action_id'] = safe_slug - action_data['action_name'] = name - except Exception as e: - print(f"LLM error: {e}") - flash(f"Failed to generate action profile: {e}") - return redirect(request.url) - else: - action_data = { - "action_id": safe_slug, - "action_name": name, - "action": { - "full_body": "", "head": "", "eyes": "", "arms": "", "hands": "", - "torso": "", "pelvis": "", "legs": "", "feet": "", "additional": "" - }, - "lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""}, - "tags": [] - } - - try: - file_path = os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(action_data, f, indent=2) - - new_action = Action( - action_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", - name=name, data=action_data - ) - db.session.add(new_action) - db.session.commit() - - flash('Action created successfully!') - return redirect(url_for('action_detail', slug=safe_slug)) - 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') - -@app.route('/action//clone', methods=['POST']) -def clone_action(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - - # Find the next available number for the clone - base_id = action.action_id - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['ACTIONS_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = action.data.copy() - new_data['action_id'] = new_id - new_data['action_name'] = f"{action.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_action = Action( - action_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['action_name'], data=new_data - ) - db.session.add(new_action) - db.session.commit() - - flash(f'Action cloned as "{new_id}"!') - return redirect(url_for('action_detail', slug=new_slug)) - -@app.route('/action//save_json', methods=['POST']) -def save_action_json(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - action.data = new_data - flag_modified(action, 'data') - db.session.commit() - if action.filename: - file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -# ============ STYLE ROUTES ============ - -@app.route('/styles') -def styles_index(): - styles = Style.query.order_by(Style.name).all() - return render_template('styles/index.html', styles=styles) - -@app.route('/styles/rescan', methods=['POST']) -def rescan_styles(): - sync_styles() - flash('Database synced with style files.') - return redirect(url_for('styles_index')) - -@app.route('/style/') -def style_detail(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - - # Load state from session - preferences = session.get(f'prefs_style_{slug}') - preview_image = session.get(f'preview_style_{slug}') - selected_character = session.get(f'char_style_{slug}') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"styles/{slug}/{f}" for f in files] - - return render_template('styles/detail.html', style=style, characters=characters, - preferences=preferences, preview_image=preview_image, - selected_character=selected_character, existing_previews=existing_previews) - -@app.route('/style//edit', methods=['GET', 'POST']) -def edit_style(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('styles') - - if request.method == 'POST': - try: - # 1. Update basic fields - style.name = request.form.get('style_name') - - # 2. Rebuild the data dictionary - new_data = style.data.copy() - new_data['style_name'] = style.name - - # Update style section - if 'style' in new_data: - for key in new_data['style'].keys(): - form_key = f"style_{key}" - if form_key in request.form: - new_data['style'][key] = request.form.get(form_key) - - # Update lora section - if 'lora' in new_data: - for key in new_data['lora'].keys(): - form_key = f"lora_{key}" - if form_key in request.form: - val = request.form.get(form_key) - if key == 'lora_weight': - try: val = float(val) - except: val = 1.0 - new_data['lora'][key] = val - - # LoRA weight randomization bounds - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - else: - new_data.setdefault('lora', {}).pop(bound, None) - - style.data = new_data - flag_modified(style, "data") - - # 3. Write back to JSON file - style_file = style.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', style.style_id)}.json" - file_path = os.path.join(app.config['STYLES_DIR'], style_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Style updated successfully!') - return redirect(url_for('style_detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('styles/edit.html', style=style, loras=loras) - -@app.route('/style//upload', methods=['POST']) -def upload_style_image(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create style subfolder - style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") - os.makedirs(style_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(style_folder, filename) - file.save(file_path) - - # Store relative path in DB - style.image_path = f"styles/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('style_detail', slug=slug)) - -def _build_style_workflow(style_obj, character=None, selected_fields=None): - """Build and return a prepared ComfyUI workflow dict for a style generation.""" - if character: - combined_data = character.data.copy() - combined_data['character_id'] = character.character_id - combined_data['style'] = style_obj.data.get('style', {}) - - # Merge style lora triggers if present - style_lora = style_obj.data.get('lora', {}) - if style_lora.get('lora_triggers'): - if 'lora' not in combined_data: combined_data['lora'] = {} - combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {style_lora['lora_triggers']}" - - # Merge character identity and wardrobe fields into selected_fields - if selected_fields: - _ensure_character_fields(character, selected_fields) - else: - # Auto-include essential character fields (minimal set for batch/default generation) - selected_fields = [] - for key in ['base_specs', 'hair', 'eyes']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - selected_fields.append('special::name') - wardrobe = character.get_active_wardrobe() - for key in _WARDROBE_KEYS: - if wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - selected_fields.extend(['style::artist_name', 'style::artistic_style', 'lora::lora_triggers']) - - default_fields = style_obj.default_fields - active_outfit = character.active_outfit - else: - combined_data = { - 'character_id': style_obj.style_id, - 'style': style_obj.data.get('style', {}), - 'lora': style_obj.data.get('lora', {}), - 'tags': style_obj.data.get('tags', []) - } - if not selected_fields: - selected_fields = ['style::artist_name', 'style::artistic_style', 'lora::lora_triggers'] - default_fields = style_obj.default_fields - active_outfit = 'default' - - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - - _append_background(prompts, character) - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, style=style_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return workflow - -@app.route('/style//generate', methods=['POST']) -def generate_style_image(slug): - style_obj = Style.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Get selected character (if any) - character_slug = request.form.get('character_slug', '') - character = None - - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - # Save preferences - session[f'char_style_{slug}'] = character_slug - session[f'prefs_style_{slug}'] = selected_fields - session.modified = True - - # Build workflow using helper (returns workflow dict, not prompt_response) - workflow = _build_style_workflow(style_obj, character, selected_fields) - - char_label = character.name if character else 'no character' - label = f"Style: {style_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('styles', slug, Style, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - return redirect(url_for('style_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('style_detail', slug=slug)) - -@app.route('/style//save_defaults', methods=['POST']) -def save_style_defaults(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - style.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this style!') - return redirect(url_for('style_detail', slug=slug)) - -@app.route('/style//replace_cover_from_preview', methods=['POST']) -def replace_style_cover_from_preview(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - style.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('style_detail', slug=slug)) - -@app.route('/get_missing_styles') -def get_missing_styles(): - missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all() - return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} - -@app.route('/get_missing_detailers') -def get_missing_detailers(): - missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all() - return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]} - -@app.route('/clear_all_detailer_covers', methods=['POST']) -def clear_all_detailer_covers(): - detailers = Detailer.query.all() - for detailer in detailers: - detailer.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/clear_all_style_covers', methods=['POST']) -def clear_all_style_covers(): - styles = Style.query.all() - for style in styles: - style.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/styles/generate_missing', methods=['POST']) -def generate_missing_styles(): - missing = Style.query.filter( - (Style.image_path == None) | (Style.image_path == '') - ).order_by(Style.name).all() - - if not missing: - flash("No styles missing cover images.") - return redirect(url_for('styles_index')) - - all_characters = Character.query.all() - if not all_characters: - flash("No characters available to preview styles with.", "error") - return redirect(url_for('styles_index')) - - enqueued = 0 - for style_obj in missing: - character = random.choice(all_characters) - try: - workflow = _build_style_workflow(style_obj, character=character) - - _enqueue_job(f"Style: {style_obj.name} – cover", workflow, - _make_finalize('styles', style_obj.slug, Style)) - enqueued += 1 - except Exception as e: - print(f"Error queuing cover generation for style {style_obj.name}: {e}") - - flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.") - return redirect(url_for('styles_index')) - -@app.route('/styles/bulk_create', methods=['POST']) -def bulk_create_styles_from_loras(): - _s = Settings.query.first() - 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): - 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: - 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() - - 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_count += 1 - continue - - 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}") - - 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###" - - llm_response = call_llm(prompt, system_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 - - if 'lora' not in style_data: style_data['lora'] = {} - style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" - - if not style_data['lora'].get('lora_triggers'): - style_data['lora']['lora_triggers'] = name_base - 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: - style_data['lora']['lora_weight_min'] = 0.7 - 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: - json.dump(style_data, f, indent=2) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - time.sleep(0.5) - except Exception as e: - print(f"Error creating style for {filename}: {e}") - - 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.') - - return redirect(url_for('styles_index')) - -@app.route('/style/create', methods=['GET', 'POST']) -def create_style(): - if request.method == 'POST': - name = request.form.get('name') - slug = request.form.get('filename', '').strip() - - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'style' - - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - style_data = { - "style_id": safe_slug, - "style_name": name, - "style": { - "artist_name": "", - "artistic_style": "" - }, - "lora": { - "lora_name": "", - "lora_weight": 1.0, - "lora_triggers": "" - } - } - - try: - file_path = os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(style_data, f, indent=2) - - new_style = Style( - style_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", - name=name, data=style_data - ) - db.session.add(new_style) - db.session.commit() - - flash('Style created successfully!') - return redirect(url_for('style_detail', slug=safe_slug)) - 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') - -@app.route('/style//clone', methods=['POST']) -def clone_style(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - - base_id = style.style_id - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['STYLES_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = style.data.copy() - new_data['style_id'] = new_id - new_data['style_name'] = f"{style.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_style = Style( - style_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['style_name'], data=new_data - ) - db.session.add(new_style) - db.session.commit() - - flash(f'Style cloned as "{new_id}"!') - return redirect(url_for('style_detail', slug=new_slug)) - -@app.route('/style//save_json', methods=['POST']) -def save_style_json(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - style.data = new_data - flag_modified(style, 'data') - db.session.commit() - if style.filename: - file_path = os.path.join(app.config['STYLES_DIR'], style.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -# ============ SCENE ROUTES ============ - -@app.route('/scenes') -def scenes_index(): - scenes = Scene.query.order_by(Scene.name).all() - return render_template('scenes/index.html', scenes=scenes) - -@app.route('/scenes/rescan', methods=['POST']) -def rescan_scenes(): - sync_scenes() - flash('Database synced with scene files.') - return redirect(url_for('scenes_index')) - -@app.route('/scene/') -def scene_detail(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - - # Load state from session - preferences = session.get(f'prefs_scene_{slug}') - preview_image = session.get(f'preview_scene_{slug}') - selected_character = session.get(f'char_scene_{slug}') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"scenes/{slug}/{f}" for f in files] - - return render_template('scenes/detail.html', scene=scene, characters=characters, - preferences=preferences, preview_image=preview_image, - selected_character=selected_character, existing_previews=existing_previews) - -@app.route('/scene//edit', methods=['GET', 'POST']) -def edit_scene(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('scenes') - - if request.method == 'POST': - try: - # 1. Update basic fields - scene.name = request.form.get('scene_name') - - # 2. Rebuild the data dictionary - new_data = scene.data.copy() - new_data['scene_name'] = scene.name - - # Update scene section - if 'scene' in new_data: - for key in new_data['scene'].keys(): - form_key = f"scene_{key}" - if form_key in request.form: - val = request.form.get(form_key) - # Handle list for furniture/colors if they were originally lists - if key in ['furniture', 'colors'] and isinstance(new_data['scene'][key], list): - val = [v.strip() for v in val.split(',') if v.strip()] - new_data['scene'][key] = val - - # Update lora section - if 'lora' in new_data: - for key in new_data['lora'].keys(): - form_key = f"lora_{key}" - if form_key in request.form: - val = request.form.get(form_key) - if key == 'lora_weight': - try: val = float(val) - except: val = 1.0 - new_data['lora'][key] = val - - # LoRA weight randomization bounds - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - 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()] - - scene.data = new_data - flag_modified(scene, "data") - - # 3. Write back to JSON file - scene_file = scene.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', scene.scene_id)}.json" - file_path = os.path.join(app.config['SCENES_DIR'], scene_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Scene updated successfully!') - return redirect(url_for('scene_detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('scenes/edit.html', scene=scene, loras=loras) - -@app.route('/scene//upload', methods=['POST']) -def upload_scene_image(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create scene subfolder - scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") - os.makedirs(scene_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(scene_folder, filename) - file.save(file_path) - - # Store relative path in DB - scene.image_path = f"scenes/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('scene_detail', slug=slug)) - -def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None): - if character: - combined_data = character.data.copy() - combined_data['character_id'] = character.character_id - - # Update character's 'defaults' with scene details - scene_data = scene_obj.data.get('scene', {}) - - # Build scene tag string - scene_tags = [] - for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: - val = scene_data.get(key) - if val: - if isinstance(val, list): - scene_tags.extend(val) - else: - scene_tags.append(val) - - combined_data['defaults']['scene'] = ", ".join(scene_tags) - - # Merge scene lora triggers if present - scene_lora = scene_obj.data.get('lora', {}) - if scene_lora.get('lora_triggers'): - if 'lora' not in combined_data: combined_data['lora'] = {} - combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {scene_lora['lora_triggers']}" - - # Merge character identity and wardrobe fields into selected_fields - if selected_fields: - _ensure_character_fields(character, selected_fields) - else: - # Auto-include essential character fields (minimal set for batch/default generation) - selected_fields = [] - for key in ['base_specs', 'hair', 'eyes']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - selected_fields.append('special::name') - wardrobe = character.get_active_wardrobe() - for key in _WARDROBE_KEYS: - if wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - selected_fields.extend(['defaults::scene', 'lora::lora_triggers']) - - default_fields = scene_obj.default_fields - active_outfit = character.active_outfit - else: - # Scene only - no character - scene_data = scene_obj.data.get('scene', {}) - scene_tags = [] - for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: - val = scene_data.get(key) - if val: - if isinstance(val, list): scene_tags.extend(val) - else: scene_tags.append(val) - - combined_data = { - 'character_id': scene_obj.scene_id, - 'defaults': { - 'scene': ", ".join(scene_tags) - }, - 'lora': scene_obj.data.get('lora', {}), - 'tags': scene_obj.data.get('tags', []) - } - if not selected_fields: - selected_fields = ['defaults::scene', 'lora::lora_triggers'] - default_fields = scene_obj.default_fields - active_outfit = 'default' - - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - - # For scene generation, we want to ensure Node 20 is handled in _prepare_workflow - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, scene=scene_obj, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return workflow - -@app.route('/scene//generate', methods=['POST']) -def generate_scene_image(slug): - scene_obj = Scene.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Get selected character (if any) - character_slug = request.form.get('character_slug', '') - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - # Save preferences - session[f'char_scene_{slug}'] = character_slug - session[f'prefs_scene_{slug}'] = selected_fields - session.modified = True - - # Build workflow using helper - workflow = _queue_scene_generation(scene_obj, character, selected_fields) - - char_label = character.name if character else 'no character' - label = f"Scene: {scene_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('scenes', slug, Scene, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - return redirect(url_for('scene_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('scene_detail', slug=slug)) - -@app.route('/scene//save_defaults', methods=['POST']) -def save_scene_defaults(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - scene.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this scene!') - return redirect(url_for('scene_detail', slug=slug)) - -@app.route('/scene//replace_cover_from_preview', methods=['POST']) -def replace_scene_cover_from_preview(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - scene.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('scene_detail', slug=slug)) - -@app.route('/scenes/bulk_create', methods=['POST']) -def bulk_create_scenes_from_loras(): - _s = Settings.query.first() - backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/') - _lora_subfolder = os.path.basename(backgrounds_lora_dir) - if not os.path.exists(backgrounds_lora_dir): - flash('Backgrounds LoRA directory not found.', 'error') - return redirect(url_for('scenes_index')) - - overwrite = request.form.get('overwrite') == 'true' - created_count = 0 - skipped_count = 0 - overwritten_count = 0 - - 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')) - - for filename in os.listdir(backgrounds_lora_dir): - if filename.endswith('.safetensors'): - name_base = filename.rsplit('.', 1)[0] - scene_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) - 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) - - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - continue - - html_filename = f"{name_base}.html" - html_path = os.path.join(backgrounds_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() - # 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}") - - 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###" - - llm_response = call_llm(prompt, system_prompt) - - # Clean response - clean_json = llm_response.replace('```json', '').replace('```', '').strip() - scene_data = json.loads(clean_json) - - # Enforce system values while preserving LLM-extracted metadata - scene_data['scene_id'] = scene_id - scene_data['scene_name'] = scene_name - - 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 - - with open(json_path, 'w') as f: - json.dump(scene_data, f, indent=2) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - # 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.') - - return redirect(url_for('scenes_index')) - -@app.route('/scene/create', methods=['GET', 'POST']) -def create_scene(): - if request.method == 'POST': - name = request.form.get('name') - slug = request.form.get('filename', '').strip() - - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'scene' - - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - scene_data = { - "scene_id": safe_slug, - "scene_name": name, - "scene": { - "background": "", - "foreground": "", - "furniture": [], - "colors": [], - "lighting": "", - "theme": "" - }, - "lora": { - "lora_name": "", - "lora_weight": 1.0, - "lora_triggers": "" - } - } - - try: - file_path = os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(scene_data, f, indent=2) - - new_scene = Scene( - scene_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", - name=name, data=scene_data - ) - db.session.add(new_scene) - db.session.commit() - - flash('Scene created successfully!') - return redirect(url_for('scene_detail', slug=safe_slug)) - 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') - -@app.route('/scene//clone', methods=['POST']) -def clone_scene(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - - base_id = scene.scene_id - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['SCENES_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = scene.data.copy() - new_data['scene_id'] = new_id - new_data['scene_name'] = f"{scene.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_scene = Scene( - scene_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['scene_name'], data=new_data - ) - db.session.add(new_scene) - db.session.commit() - - flash(f'Scene cloned as "{new_id}"!') - return redirect(url_for('scene_detail', slug=new_slug)) - -@app.route('/scene//save_json', methods=['POST']) -def save_scene_json(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - scene.data = new_data - flag_modified(scene, 'data') - db.session.commit() - if scene.filename: - file_path = os.path.join(app.config['SCENES_DIR'], scene.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -# ============ DETAILER ROUTES ============ - -@app.route('/detailers') -def detailers_index(): - detailers = Detailer.query.order_by(Detailer.name).all() - return render_template('detailers/index.html', detailers=detailers) - -@app.route('/detailers/rescan', methods=['POST']) -def rescan_detailers(): - sync_detailers() - flash('Database synced with detailer files.') - return redirect(url_for('detailers_index')) - -@app.route('/detailer/') -def detailer_detail(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - actions = Action.query.order_by(Action.name).all() - - # Load state from session - preferences = session.get(f'prefs_detailer_{slug}') - preview_image = session.get(f'preview_detailer_{slug}') - selected_character = session.get(f'char_detailer_{slug}') - selected_action = session.get(f'action_detailer_{slug}') - extra_positive = session.get(f'extra_pos_detailer_{slug}', '') - extra_negative = session.get(f'extra_neg_detailer_{slug}', '') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"detailers/{slug}/{f}" for f in files] - - return render_template('detailers/detail.html', detailer=detailer, characters=characters, - actions=actions, preferences=preferences, preview_image=preview_image, - selected_character=selected_character, selected_action=selected_action, - extra_positive=extra_positive, extra_negative=extra_negative, - existing_previews=existing_previews) - -@app.route('/detailer//edit', methods=['GET', 'POST']) -def edit_detailer(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - loras = get_available_loras('detailers') - - if request.method == 'POST': - try: - # 1. Update basic fields - detailer.name = request.form.get('detailer_name') - - # 2. Rebuild the data dictionary - new_data = detailer.data.copy() - new_data['detailer_name'] = detailer.name - - # Update prompt (stored as a plain string) - new_data['prompt'] = request.form.get('detailer_prompt', '') - - # Update lora section - if 'lora' in new_data: - for key in new_data['lora'].keys(): - form_key = f"lora_{key}" - if form_key in request.form: - val = request.form.get(form_key) - if key == 'lora_weight': - try: val = float(val) - except: val = 1.0 - new_data['lora'][key] = val - - # LoRA weight randomization bounds - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data.setdefault('lora', {})[bound] = float(val_str) - except ValueError: - pass - 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()] - - detailer.data = new_data - flag_modified(detailer, "data") - - # 3. Write back to JSON file - detailer_file = detailer.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', detailer.detailer_id)}.json" - file_path = os.path.join(app.config['DETAILERS_DIR'], detailer_file) - - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - db.session.commit() - flash('Detailer updated successfully!') - return redirect(url_for('detailer_detail', slug=slug)) - - except Exception as e: - print(f"Edit error: {e}") - flash(f"Error saving changes: {str(e)}") - - return render_template('detailers/edit.html', detailer=detailer, loras=loras) - -@app.route('/detailer//upload', methods=['POST']) -def upload_detailer_image(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create detailer subfolder - detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") - os.makedirs(detailer_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(detailer_folder, filename) - file.save(file_path) - - # Store relative path in DB - detailer.image_path = f"detailers/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('detailer_detail', slug=slug)) - -def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None): - if character: - combined_data = character.data.copy() - combined_data['character_id'] = character.character_id - - # Merge detailer prompt into character's tags - 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', {}) - if detailer_lora.get('lora_triggers'): - if 'lora' not in combined_data: combined_data['lora'] = {} - combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {detailer_lora['lora_triggers']}" - - # Merge character identity and wardrobe fields into selected_fields - if selected_fields: - _ensure_character_fields(character, selected_fields) - else: - # Auto-include essential character fields (minimal set for batch/default generation) - selected_fields = [] - for key in ['base_specs', 'hair', 'eyes']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - selected_fields.append('special::name') - wardrobe = character.get_active_wardrobe() - for key in _WARDROBE_KEYS: - if wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - selected_fields.extend(['special::tags', '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'] - default_fields = detailer_obj.default_fields - active_outfit = 'default' - - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - - _append_background(prompts, character) - - if extra_positive: - prompts["main"] = f"{prompts['main']}, {extra_positive}" - - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, detailer=detailer_obj, action=action, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - return workflow - -@app.route('/detailer//generate', methods=['POST']) -def generate_detailer_image(slug): - detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404() - - try: - # Get action type - action = request.form.get('action', 'preview') - - # Get selected fields - selected_fields = request.form.getlist('include_field') - - # Get selected character (if any) - character_slug = request.form.get('character_slug', '') - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - # Get selected action (if any) - action_slug = request.form.get('action_slug', '') - action_obj = Action.query.filter_by(slug=action_slug).first() if action_slug else None - - # Get additional prompts - extra_positive = request.form.get('extra_positive', '').strip() - extra_negative = request.form.get('extra_negative', '').strip() - - # Save preferences - session[f'char_detailer_{slug}'] = character_slug - session[f'action_detailer_{slug}'] = action_slug - session[f'extra_pos_detailer_{slug}'] = extra_positive - session[f'extra_neg_detailer_{slug}'] = extra_negative - session[f'prefs_detailer_{slug}'] = selected_fields - session.modified = True - - # Build workflow using helper - workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative) - - char_label = character.name if character else 'no character' - label = f"Detailer: {detailer_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - - return redirect(url_for('detailer_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('detailer_detail', slug=slug)) - -@app.route('/detailer//save_defaults', methods=['POST']) -def save_detailer_defaults(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - detailer.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this detailer!') - return redirect(url_for('detailer_detail', slug=slug)) - -@app.route('/detailer//replace_cover_from_preview', methods=['POST']) -def replace_detailer_cover_from_preview(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - detailer.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('detailer_detail', slug=slug)) - -@app.route('/detailer//save_json', methods=['POST']) -def save_detailer_json(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - detailer.data = new_data - flag_modified(detailer, 'data') - db.session.commit() - if detailer.filename: - file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -@app.route('/detailers/bulk_create', methods=['POST']) -def bulk_create_detailers_from_loras(): - _s = Settings.query.first() - detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/') - _lora_subfolder = os.path.basename(detailers_lora_dir) - if not os.path.exists(detailers_lora_dir): - flash('Detailers LoRA directory not found.', 'error') - return redirect(url_for('detailers_index')) - - overwrite = request.form.get('overwrite') == 'true' - created_count = 0 - skipped_count = 0 - overwritten_count = 0 - - 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')) - - for filename in os.listdir(detailers_lora_dir): - if filename.endswith('.safetensors'): - name_base = filename.rsplit('.', 1)[0] - detailer_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) - 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) - - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - continue - - html_filename = f"{name_base}.html" - html_path = os.path.join(detailers_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}") - - 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###" - - 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 - - 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 - - with open(json_path, 'w') as f: - json.dump(detailer_data, f, indent=2) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - # 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}") - - 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.') - - return redirect(url_for('detailers_index')) - -@app.route('/detailer/create', methods=['GET', 'POST']) -def create_detailer(): - if request.method == 'POST': - name = request.form.get('name') - slug = request.form.get('filename', '').strip() - - if not slug: - slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') - - safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) - if not safe_slug: - safe_slug = 'detailer' - - base_slug = safe_slug - counter = 1 - while os.path.exists(os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json")): - safe_slug = f"{base_slug}_{counter}" - counter += 1 - - detailer_data = { - "detailer_id": safe_slug, - "detailer_name": name, - "prompt": "", - "lora": { - "lora_name": "", - "lora_weight": 1.0, - "lora_triggers": "" - } - } - - try: - file_path = os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json") - with open(file_path, 'w') as f: - json.dump(detailer_data, f, indent=2) - - new_detailer = Detailer( - detailer_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", - name=name, data=detailer_data - ) - db.session.add(new_detailer) - db.session.commit() - - flash('Detailer created successfully!') - return redirect(url_for('detailer_detail', slug=safe_slug)) - 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') - - -# --------------------------------------------------------------------------- -# Checkpoints -# --------------------------------------------------------------------------- - -@app.route('/checkpoints') -def checkpoints_index(): - checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() - return render_template('checkpoints/index.html', checkpoints=checkpoints) - -@app.route('/checkpoints/rescan', methods=['POST']) -def rescan_checkpoints(): - sync_checkpoints() - flash('Checkpoint list synced from disk.') - return redirect(url_for('checkpoints_index')) - -@app.route('/checkpoint/') -def checkpoint_detail(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - preview_image = session.get(f'preview_checkpoint_{slug}') - selected_character = session.get(f'char_checkpoint_{slug}') - - # List existing preview images - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") - existing_previews = [] - if os.path.isdir(upload_dir): - files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) - existing_previews = [f"checkpoints/{slug}/{f}" for f in files] - - return render_template('checkpoints/detail.html', ckpt=ckpt, characters=characters, - preview_image=preview_image, selected_character=selected_character, - existing_previews=existing_previews) - -@app.route('/checkpoint//upload', methods=['POST']) -def upload_checkpoint_image(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file part') - return redirect(url_for('checkpoint_detail', slug=slug)) - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(url_for('checkpoint_detail', slug=slug)) - if file and allowed_file(file.filename): - folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") - os.makedirs(folder, exist_ok=True) - filename = secure_filename(file.filename) - file.save(os.path.join(folder, filename)) - ckpt.image_path = f"checkpoints/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - return redirect(url_for('checkpoint_detail', slug=slug)) - -def _apply_checkpoint_settings(workflow, ckpt_data): - """Apply checkpoint-specific sampler/prompt/VAE settings to the workflow.""" - steps = ckpt_data.get('steps') - cfg = ckpt_data.get('cfg') - sampler_name = ckpt_data.get('sampler_name') - scheduler = ckpt_data.get('scheduler') - base_positive = ckpt_data.get('base_positive', '') - base_negative = ckpt_data.get('base_negative', '') - vae = ckpt_data.get('vae', 'integrated') - - # KSampler (node 3) - if steps and '3' in workflow: - workflow['3']['inputs']['steps'] = int(steps) - if cfg and '3' in workflow: - workflow['3']['inputs']['cfg'] = float(cfg) - if sampler_name and '3' in workflow: - workflow['3']['inputs']['sampler_name'] = sampler_name - if scheduler and '3' in workflow: - workflow['3']['inputs']['scheduler'] = scheduler - - # Face/hand detailers (nodes 11, 13) - for node_id in ['11', '13']: - if node_id in workflow: - if steps: - workflow[node_id]['inputs']['steps'] = int(steps) - if cfg: - workflow[node_id]['inputs']['cfg'] = float(cfg) - if sampler_name: - workflow[node_id]['inputs']['sampler_name'] = sampler_name - if scheduler: - workflow[node_id]['inputs']['scheduler'] = scheduler - - # Prepend base_positive to positive prompts (main + face/hand detailers) - if base_positive: - for node_id in ['6', '14', '15']: - if node_id in workflow: - workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}" - - # Append base_negative to negative prompt (shared by main + detailers via node 7) - if base_negative and '7' in workflow: - workflow['7']['inputs']['text'] = f"{workflow['7']['inputs']['text']}, {base_negative}" - - # VAE: if not integrated, inject a VAELoader node and rewire - if vae and vae != 'integrated': - workflow['21'] = { - 'inputs': {'vae_name': vae}, - 'class_type': 'VAELoader' - } - if '8' in workflow: - workflow['8']['inputs']['vae'] = ['21', 0] - for node_id in ['11', '13']: - if node_id in workflow: - workflow[node_id]['inputs']['vae'] = ['21', 0] - - return workflow - -def _build_checkpoint_workflow(ckpt_obj, character=None): - """Build and return a prepared ComfyUI workflow dict for a checkpoint generation.""" - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - if character: - combined_data = character.data.copy() - combined_data['character_id'] = character.character_id - selected_fields = [] - for key in ['base_specs', 'hair', 'eyes']: - if character.data.get('identity', {}).get(key): - selected_fields.append(f'identity::{key}') - selected_fields.append('special::name') - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'top', 'bottom']: - if wardrobe.get(key): - selected_fields.append(f'wardrobe::{key}') - prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit) - _append_background(prompts, character) - else: - prompts = { - "main": "masterpiece, best quality, 1girl, solo, simple background, looking at viewer", - "face": "masterpiece, best quality", - "hand": "masterpiece, best quality", - } - - workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_obj.checkpoint_path, - checkpoint_data=ckpt_obj.data or {}) - return workflow - -@app.route('/checkpoint//generate', methods=['POST']) -def generate_checkpoint_image(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - try: - character_slug = request.form.get('character_slug', '') - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - - session[f'char_checkpoint_{slug}'] = character_slug - session.modified = True - workflow = _build_checkpoint_workflow(ckpt, character) - - char_label = character.name if character else 'random' - label = f"Checkpoint: {ckpt.name} ({char_label})" - job = _enqueue_job(label, workflow, _make_finalize('checkpoints', slug, Checkpoint)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - return redirect(url_for('checkpoint_detail', slug=slug)) - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('checkpoint_detail', slug=slug)) - -@app.route('/checkpoint//replace_cover_from_preview', methods=['POST']) -def replace_checkpoint_cover_from_preview(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - ckpt.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('checkpoint_detail', slug=slug)) - -@app.route('/checkpoint//save_json', methods=['POST']) -def save_checkpoint_json(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - ckpt.data = new_data - flag_modified(ckpt, 'data') - db.session.commit() - checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') - file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json') - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -@app.route('/get_missing_checkpoints') -def get_missing_checkpoints(): - missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all() - return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} - -@app.route('/clear_all_checkpoint_covers', methods=['POST']) -def clear_all_checkpoint_covers(): - for ckpt in Checkpoint.query.all(): - ckpt.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/checkpoints/bulk_create', methods=['POST']) -def bulk_create_checkpoints(): - checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') - os.makedirs(checkpoints_dir, exist_ok=True) - - overwrite = request.form.get('overwrite') == 'true' - created_count = 0 - skipped_count = 0 - overwritten_count = 0 - - system_prompt = load_prompt('checkpoint_system.txt') - if not system_prompt: - flash('Checkpoint system prompt file not found.', 'error') - return redirect(url_for('checkpoints_index')) - - dirs = [ - (app.config.get('ILLUSTRIOUS_MODELS_DIR', ''), 'Illustrious'), - (app.config.get('NOOB_MODELS_DIR', ''), 'Noob'), - ] - - for dirpath, family in dirs: - if not dirpath or not os.path.exists(dirpath): - continue - - for filename in sorted(os.listdir(dirpath)): - if not (filename.endswith('.safetensors') or filename.endswith('.ckpt')): - continue - - checkpoint_path = f"{family}/{filename}" - name_base = filename.rsplit('.', 1)[0] - safe_id = re.sub(r'[^a-zA-Z0-9_]', '_', checkpoint_path.rsplit('.', 1)[0]).lower().strip('_') - json_filename = f"{safe_id}.json" - json_path = os.path.join(checkpoints_dir, json_filename) - - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - continue - - # Look for a matching HTML file alongside the model file - html_path = os.path.join(dirpath, f"{name_base}.html") - html_content = "" - if os.path.exists(html_path): - try: - with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: - html_raw = hf.read() - 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 for {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 - else: - ckpt_data = defaults - - 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}") - - 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.') - - return redirect(url_for('checkpoints_index')) - -# ============ LOOK ROUTES ============ - -@app.route('/looks') -def looks_index(): - looks = Look.query.order_by(Look.name).all() - look_assignments = _count_look_assignments() - return render_template('looks/index.html', looks=looks, look_assignments=look_assignments) - -@app.route('/looks/rescan', methods=['POST']) -def rescan_looks(): - sync_looks() - flash('Database synced with look files.') - return redirect(url_for('looks_index')) - -@app.route('/look/') -def look_detail(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - - # Pre-select the linked character if set - preferences = session.get(f'prefs_look_{slug}') - preview_image = session.get(f'preview_look_{slug}') - selected_character = session.get(f'char_look_{slug}', look.character_id or '') - - return render_template('looks/detail.html', look=look, characters=characters, - preferences=preferences, preview_image=preview_image, - selected_character=selected_character) - -@app.route('/look//edit', methods=['GET', 'POST']) -def edit_look(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - characters = Character.query.order_by(Character.name).all() - loras = get_available_loras('characters') - - if request.method == 'POST': - look.name = request.form.get('look_name', look.name) - character_id = request.form.get('character_id', '') - look.character_id = character_id if character_id else None - - new_data = look.data.copy() - new_data['look_name'] = look.name - new_data['character_id'] = look.character_id - - new_data['positive'] = request.form.get('positive', '') - new_data['negative'] = request.form.get('negative', '') - - 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', '') - new_data['lora'] = {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers} - for bound in ['lora_weight_min', 'lora_weight_max']: - val_str = request.form.get(f'lora_{bound}', '').strip() - if val_str: - try: - new_data['lora'][bound] = float(val_str) - except ValueError: - pass - - tags_raw = request.form.get('tags', '') - new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] - - look.data = new_data - flag_modified(look, 'data') - db.session.commit() - - if look.filename: - file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - flash(f'Look "{look.name}" updated!') - return redirect(url_for('look_detail', slug=look.slug)) - - return render_template('looks/edit.html', look=look, characters=characters, loras=loras) - -@app.route('/look//upload', methods=['POST']) -def upload_look_image(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file selected') - return redirect(url_for('look_detail', slug=slug)) - file = request.files['image'] - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}') - os.makedirs(look_folder, exist_ok=True) - file_path = os.path.join(look_folder, filename) - file.save(file_path) - look.image_path = f'looks/{slug}/{filename}' - db.session.commit() - return redirect(url_for('look_detail', slug=slug)) - -@app.route('/look//generate', methods=['POST']) -def generate_look_image(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - - try: - action = request.form.get('action', 'preview') - selected_fields = request.form.getlist('include_field') - - character_slug = request.form.get('character_slug', '') - character = None - - # Only load a character when the user explicitly selects one - character = _resolve_character(character_slug) - if character_slug == '__random__' and character: - character_slug = character.slug - elif character_slug and not character: - # fallback: try matching by character_id - character = Character.query.filter_by(character_id=character_slug).first() - # No fallback to look.character_id — looks are self-contained - - session[f'prefs_look_{slug}'] = selected_fields - session[f'char_look_{slug}'] = character_slug - session.modified = True - - lora_triggers = look.data.get('lora', {}).get('lora_triggers', '') - look_positive = look.data.get('positive', '') - - with open('comfy_workflow.json', 'r') as f: - workflow = json.load(f) - - if character: - # Merge character identity with look LoRA and positive prompt - combined_data = { - 'character_id': character.character_id, - 'identity': character.data.get('identity', {}), - 'defaults': character.data.get('defaults', {}), - 'wardrobe': character.data.get('wardrobe', {}).get(character.active_outfit or 'default', - character.data.get('wardrobe', {}).get('default', {})), - 'styles': character.data.get('styles', {}), - 'lora': look.data.get('lora', {}), - 'tags': look.data.get('tags', []) - } - _ensure_character_fields(character, selected_fields, - include_wardrobe=False, include_defaults=True) - prompts = build_prompt(combined_data, selected_fields, character.default_fields) - # Append look-specific triggers and positive - extra = ', '.join(filter(None, [lora_triggers, look_positive])) - if extra: - prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra}" if prompts['main'] else extra) - primary_color = character.data.get('styles', {}).get('primary_color', '') - bg = f"{primary_color} simple background" if primary_color else "simple background" - else: - # Look is self-contained: build prompt from its own positive and triggers only - main = _dedup_tags(', '.join(filter(None, ['(solo:1.2)', lora_triggers, look_positive]))) - prompts = {'main': main, 'face': '', 'hand': ''} - bg = "simple background" - - prompts['main'] = _dedup_tags(f"{prompts['main']}, {bg}" if prompts['main'] else bg) - - ckpt_path, ckpt_data = _get_default_checkpoint() - workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, - checkpoint_data=ckpt_data, look=look) - - char_label = character.name if character else 'no character' - label = f"Look: {look.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _make_finalize('looks', slug, Look, action)) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'status': 'queued', 'job_id': job['id']} - return redirect(url_for('look_detail', slug=slug)) - - except Exception as e: - print(f"Generation error: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'error': str(e)}, 500 - flash(f"Error during generation: {str(e)}") - return redirect(url_for('look_detail', slug=slug)) - -@app.route('/look//replace_cover_from_preview', methods=['POST']) -def replace_look_cover_from_preview(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - look.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('look_detail', slug=slug)) - -@app.route('/look//save_defaults', methods=['POST']) -def save_look_defaults(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - look.default_fields = request.form.getlist('include_field') - db.session.commit() - flash('Default prompt selection saved!') - return redirect(url_for('look_detail', slug=slug)) - -@app.route('/look//save_json', methods=['POST']) -def save_look_json(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - look.data = new_data - look.character_id = new_data.get('character_id', look.character_id) - flag_modified(look, 'data') - db.session.commit() - if look.filename: - file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - -@app.route('/look/create', methods=['GET', 'POST']) -def create_look(): - characters = Character.query.order_by(Character.name).all() - loras = get_available_loras('characters') - 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()] - - data = { - 'look_id': look_id, - 'look_name': name, - 'character_id': character_id, - 'positive': positive, - 'negative': negative, - 'lora': {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers}, - 'tags': tags - } - - 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() - - flash(f'Look "{name}" created!') - return redirect(url_for('look_detail', slug=slug)) - - return render_template('looks/create.html', characters=characters, loras=loras) - -@app.route('/get_missing_looks') -def get_missing_looks(): - missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all() - return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} - -@app.route('/clear_all_look_covers', methods=['POST']) -def clear_all_look_covers(): - looks = Look.query.all() - for look in looks: - look.image_path = None - db.session.commit() - return {'success': True} - -@app.route('/looks/bulk_create', methods=['POST']) -def bulk_create_looks_from_loras(): - _s = Settings.query.first() - lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/') - _lora_subfolder = os.path.basename(lora_dir) - if not os.path.exists(lora_dir): - flash('Looks LoRA directory not found.', 'error') - return redirect(url_for('looks_index')) - - overwrite = request.form.get('overwrite') == 'true' - created_count = 0 - skipped_count = 0 - overwritten_count = 0 - - 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')) - - for filename in os.listdir(lora_dir): - if not filename.endswith('.safetensors'): - continue - - name_base = filename.rsplit('.', 1)[0] - look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) - 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) - - is_existing = os.path.exists(json_path) - if is_existing and not overwrite: - skipped_count += 1 - continue - - html_filename = f"{name_base}.html" - html_path = os.path.join(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}") - - 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###" - - 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 - - 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) - - if is_existing: - overwritten_count += 1 - else: - created_count += 1 - - time.sleep(0.5) - - except Exception as e: - print(f"Error creating look for {filename}: {e}") - - 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.') - - return redirect(url_for('looks_index')) - -# --------------------------------------------------------------------------- -# Gallery -# --------------------------------------------------------------------------- - -GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints'] - -_MODEL_MAP = { - 'characters': Character, - 'actions': Action, - 'outfits': Outfit, - 'scenes': Scene, - 'styles': Style, - 'detailers': Detailer, - 'checkpoints': Checkpoint, -} - - -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'] - images = [] - cats = GALLERY_CATEGORIES if category_filter == 'all' else [category_filter] - - for cat in 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: - if slug_filter and slug_filter != item_slug: - continue - 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(('.png', '.jpg', '.jpeg', '.webp')): - continue - try: - ts = int(filename.replace('gen_', '').rsplit('.', 1)[0]) - except ValueError: - ts = 0 - images.append({ - 'path': f"{cat}/{item_slug}/{filename}", - 'category': cat, - 'slug': item_slug, - 'filename': filename, - 'timestamp': ts, - }) - - images.sort(key=lambda x: x['timestamp'], reverse=True) - return images - - -def _enrich_with_names(images): - """Add item_name field to each image dict, querying DB once per category.""" - by_cat = {} - for img in images: - by_cat.setdefault(img['category'], set()).add(img['slug']) - - name_map = {} - for cat, slugs in by_cat.items(): - Model = _MODEL_MAP.get(cat) - if not Model: - continue - items = Model.query.filter(Model.slug.in_(slugs)).with_entities(Model.slug, Model.name).all() - for slug, name in items: - name_map[(cat, slug)] = name - - for img in images: - img['item_name'] = name_map.get((img['category'], img['slug']), img['slug']) - return images - - -@app.route('/gallery') -def gallery(): - category = request.args.get('category', 'all') - slug = request.args.get('slug', '') - sort = request.args.get('sort', 'newest') - 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) - - if sort == 'oldest': - images.reverse() - - total = len(images) - total_pages = max(1, (total + per_page - 1) // per_page) - page = min(page, total_pages) - page_images = images[(page - 1) * per_page: page * per_page] - _enrich_with_names(page_images) - - slug_options = [] - if category != 'all': - Model = _MODEL_MAP.get(category) - if Model: - slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()] - - return render_template( - 'gallery.html', - images=page_images, - page=page, - per_page=per_page, - total=total, - total_pages=total_pages, - category=category, - slug=slug, - sort=sort, - categories=GALLERY_CATEGORIES, - slug_options=slug_options, - ) - - -def _parse_comfy_png_metadata(image_path): - """Read ComfyUI generation metadata from a PNG's tEXt 'prompt' chunk. - - Returns a dict with keys: positive, negative, checkpoint, loras, - seed, steps, cfg, sampler, scheduler. Any missing field is None/[]. - """ - from PIL import Image as PilImage - - result = { - 'positive': None, - 'negative': None, - 'checkpoint': None, - 'loras': [], # list of {name, strength} - 'seed': None, - 'steps': None, - 'cfg': None, - 'sampler': None, - 'scheduler': None, - } - - try: - with PilImage.open(image_path) as im: - raw = im.info.get('prompt') - if not raw: - return result - nodes = json.loads(raw) - except Exception: - return result - - for node in nodes.values(): - ct = node.get('class_type', '') - inp = node.get('inputs', {}) - - if ct == 'KSampler': - result['seed'] = inp.get('seed') - result['steps'] = inp.get('steps') - result['cfg'] = inp.get('cfg') - result['sampler'] = inp.get('sampler_name') - result['scheduler'] = inp.get('scheduler') - - elif ct == 'CheckpointLoaderSimple': - result['checkpoint'] = inp.get('ckpt_name') - - elif ct == 'CLIPTextEncode': - # Identify positive vs negative by which KSampler input they connect to. - # Simpler heuristic: node "6" = positive, node "7" = negative (our fixed workflow). - # But to be robust, we check both via node graph references where possible. - # Fallback: first CLIPTextEncode = positive, second = negative. - text = inp.get('text', '') - if result['positive'] is None: - result['positive'] = text - elif result['negative'] is None: - result['negative'] = text - - elif ct == 'LoraLoader': - name = inp.get('lora_name', '') - if name: - result['loras'].append({ - 'name': name, - 'strength': inp.get('strength_model', 1.0), - }) - - # Re-parse with fixed node IDs from the known workflow (more reliable) - try: - if '6' in nodes: - result['positive'] = nodes['6']['inputs'].get('text', result['positive']) - if '7' in nodes: - result['negative'] = nodes['7']['inputs'].get('text', result['negative']) - except Exception: - pass - - return result - - -@app.route('/gallery/prompt-data') -def gallery_prompt_data(): - """Return generation metadata for a specific image by reading its PNG tEXt chunk.""" - img_path = request.args.get('path', '') - if not img_path: - return {'error': 'path parameter required'}, 400 - - # Validate path stays within uploads folder - 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): - return {'error': 'Invalid path'}, 400 - if not os.path.isfile(abs_img): - return {'error': 'File not found'}, 404 - - meta = _parse_comfy_png_metadata(abs_img) - meta['path'] = img_path - return meta - - -@app.route('/gallery/delete', methods=['POST']) -def gallery_delete(): - """Delete a generated image from the gallery. Only the image file is removed.""" - data = request.get_json(silent=True) or {} - img_path = data.get('path', '') - - if not img_path: - return {'error': 'path required'}, 400 - - if len(img_path.split('/')) != 3: - return {'error': 'invalid path format'}, 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): - return {'error': 'Invalid path'}, 400 - - if os.path.isfile(abs_img): - os.remove(abs_img) - - return {'status': 'ok'} - - -@app.route('/resource///delete', methods=['POST']) -def resource_delete(category, slug): - """Delete a resource item from a category gallery. - - soft: removes JSON data file + DB record; LoRA/checkpoint file kept on disk. - hard: removes JSON data file + LoRA/checkpoint safetensors + DB record. - """ - _RESOURCE_MODEL_MAP = { - 'looks': Look, - 'styles': Style, - 'actions': Action, - 'outfits': Outfit, - 'scenes': Scene, - 'detailers': Detailer, - 'checkpoints': Checkpoint, - } - _RESOURCE_DATA_DIRS = { - 'looks': app.config['LOOKS_DIR'], - 'styles': app.config['STYLES_DIR'], - 'actions': app.config['ACTIONS_DIR'], - 'outfits': app.config['CLOTHING_DIR'], - 'scenes': app.config['SCENES_DIR'], - 'detailers': app.config['DETAILERS_DIR'], - 'checkpoints': app.config['CHECKPOINTS_DIR'], - } - _LORA_BASE = '/ImageModels/lora/' - - if category not in _RESOURCE_MODEL_MAP: - return {'error': 'unknown category'}, 400 - - req = request.get_json(silent=True) or {} - mode = req.get('mode', 'soft') - - data_dir = _RESOURCE_DATA_DIRS[category] - json_path = os.path.join(data_dir, f'{slug}.json') - - deleted = [] - asset_abs = None - - # Resolve asset path before deleting JSON (hard only) - if mode == 'hard' and os.path.isfile(json_path): - try: - with open(json_path) as f: - item_data = json.load(f) - if category == 'checkpoints': - ckpt_rel = item_data.get('checkpoint_path', '') - if ckpt_rel.startswith('Illustrious/'): - asset_abs = os.path.join(app.config['ILLUSTRIOUS_MODELS_DIR'], - ckpt_rel[len('Illustrious/'):]) - elif ckpt_rel.startswith('Noob/'): - asset_abs = os.path.join(app.config['NOOB_MODELS_DIR'], - ckpt_rel[len('Noob/'):]) - else: - lora_name = item_data.get('lora', {}).get('lora_name', '') - if lora_name: - asset_abs = os.path.join(_LORA_BASE, lora_name) - except Exception: - pass - - # Delete JSON - if os.path.isfile(json_path): - os.remove(json_path) - deleted.append('json') - - # Delete LoRA/checkpoint file (hard only) - if mode == 'hard' and asset_abs and os.path.isfile(asset_abs): - os.remove(asset_abs) - deleted.append('lora' if category != 'checkpoints' else 'checkpoint') - - # Remove DB record - Model = _RESOURCE_MODEL_MAP[category] - rec = Model.query.filter_by(slug=slug).first() - if rec: - db.session.delete(rec) - db.session.commit() - deleted.append('db') - - return {'status': 'ok', 'deleted': deleted} - - -# --------------------------------------------------------------------------- -# Strengths Gallery -# --------------------------------------------------------------------------- - -_STRENGTHS_MODEL_MAP = { - 'characters': Character, - 'looks': Look, - 'outfits': Outfit, - 'actions': Action, - 'styles': Style, - 'scenes': Scene, - 'detailers': Detailer, -} - -# Which ComfyUI LoRA node each category occupies -_CATEGORY_LORA_NODES = { - 'characters': '16', - 'looks': '16', - 'outfits': '17', - 'actions': '18', - 'styles': '19', - 'scenes': '19', - 'detailers': '19', -} - - -def _build_strengths_prompts(category, entity, character, action=None, extra_positive=''): - """Build main/face/hand prompt strings for the Strengths Gallery. - - Only includes prompt *content* from the entity and (optionally) the - character. LoRA triggers from other nodes are intentionally excluded - so the result reflects only the swept LoRA's contribution. - - action — optional Action model object (used for detailer category) - extra_positive — additional free-text to append to the main prompt - """ - if category == 'characters': - # The entity IS the character — build its full prompt normally - return build_prompt(entity.data, [], entity.default_fields) - - if category == 'looks': - # Start with linked character data, prepend Look positive tags - base = build_prompt(character.data, [], character.default_fields) if character else {'main': '', 'face': '', 'hand': ''} - look_pos = entity.data.get('positive', '') - look_triggers = entity.data.get('lora', {}).get('lora_triggers', '') - prefix_parts = [p for p in [look_triggers, look_pos] if p] - prefix = ', '.join(prefix_parts) - if prefix: - base['main'] = f"{prefix}, {base['main']}" if base['main'] else prefix - return base - - 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 = [] - hand_parts = [] - if character: - identity = character.data.get('identity', {}) - defaults = character.data.get('defaults', {}) - char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), - identity.get('eyes'), defaults.get('expression')] if v] - face_parts = [v for v in [identity.get('hair'), identity.get('eyes'), - defaults.get('expression')] if v] - hand_parts = [v for v in [wardrobe.get('hands'), wardrobe.get('gloves')] if v] - main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags - return { - 'main': _dedup_tags(', '.join(p for p in main_parts if p)), - 'face': _dedup_tags(', '.join(face_parts)), - 'hand': _dedup_tags(', '.join(hand_parts)), - } - - if category == 'actions': - action_data = entity.data.get('action', {}) - action_triggers = entity.data.get('lora', {}).get('lora_triggers', '') - tags = entity.data.get('tags', []) - pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional'] - pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)] - expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)] - char_parts = [] - face_parts = list(expr_parts) - hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else [] - if character: - identity = character.data.get('identity', {}) - char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), - identity.get('eyes')] if v] - face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v] - main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags - return { - 'main': _dedup_tags(', '.join(p for p in main_parts if p)), - 'face': _dedup_tags(', '.join(face_parts)), - 'hand': _dedup_tags(', '.join(hand_parts)), - } - - # styles / scenes / detailers — character prompt + entity tags/triggers - 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] - 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] - else: # detailers - det_prompt = entity.data.get('prompt', '') - entity_parts = [p for p in [entity_triggers, det_prompt] + tags if p] - - base = build_prompt(character.data, [], character.default_fields) if character else {'main': '', 'face': '', 'hand': ''} - entity_str = ', '.join(entity_parts) - if entity_str: - base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str - - # Incorporate action prompt fields (for detailer category) - if action is not None: - action_data = action.data.get('action', {}) - action_parts = [action_data.get(k, '') for k in - ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes'] - if action_data.get(k)] - action_str = ', '.join(action_parts) - if action_str: - base['main'] = f"{base['main']}, {action_str}" if base['main'] else action_str - - # Append any extra positive text - if extra_positive: - base['main'] = f"{base['main']}, {extra_positive}" if base['main'] else extra_positive - - return base - - -def _prepare_strengths_workflow(workflow, category, entity, character, prompts, - checkpoint, ckpt_data, strength_value, fixed_seed, - custom_negative=''): - """Wire a ComfyUI workflow with ONLY the entity's LoRA active at a specific strength. - - All other LoRA nodes are bypassed. A fixed seed ensures every step in the - Strengths Gallery sweep produces a comparably composed image. - """ - active_node = _CATEGORY_LORA_NODES.get(category, '16') - entity_lora = entity.data.get('lora', {}) - entity_lora_name = entity_lora.get('lora_name', '') - - # 1. Set checkpoint - if checkpoint and '4' in workflow: - workflow['4']['inputs']['ckpt_name'] = checkpoint - - # 2. Default resolution - if '5' in workflow: - workflow['5']['inputs']['width'] = 1024 - workflow['5']['inputs']['height'] = 1024 - - # 3. Inject prompts - if '6' in workflow: - workflow['6']['inputs']['text'] = workflow['6']['inputs']['text'].replace( - '{{POSITIVE_PROMPT}}', prompts.get('main', '')) - if '14' in workflow: - workflow['14']['inputs']['text'] = workflow['14']['inputs']['text'].replace( - '{{FACE_PROMPT}}', prompts.get('face', '')) - if '15' in workflow: - workflow['15']['inputs']['text'] = workflow['15']['inputs']['text'].replace( - '{{HAND_PROMPT}}', prompts.get('hand', '')) - - # For looks, prepend the look's negative to node 7 - if category == 'looks': - look_neg = entity.data.get('negative', '') - if look_neg and '7' in workflow: - workflow['7']['inputs']['text'] = f"{look_neg}, {workflow['7']['inputs']['text']}" - - # Prepend any custom negative (e.g. extra_neg from detailer session) - if custom_negative and '7' in workflow: - workflow['7']['inputs']['text'] = f"{custom_negative}, {workflow['7']['inputs']['text']}" - - # 4. Wire LoRA chain — only activate the entity's node; skip all others - model_source = ['4', 0] - clip_source = ['4', 1] - - for node_id in ['16', '17', '18', '19']: - if node_id not in workflow: - continue - if node_id == active_node and entity_lora_name: - workflow[node_id]['inputs']['lora_name'] = entity_lora_name - workflow[node_id]['inputs']['strength_model'] = float(strength_value) - workflow[node_id]['inputs']['strength_clip'] = float(strength_value) - workflow[node_id]['inputs']['model'] = list(model_source) - workflow[node_id]['inputs']['clip'] = list(clip_source) - model_source = [node_id, 0] - clip_source = [node_id, 1] - # else: skip — model_source/clip_source pass through unchanged - - # 5. Wire all consumers to the final model/clip source - for consumer, needs_model, needs_clip in [ - ('3', True, False), - ('6', False, True), - ('7', False, True), - ('11', True, True), - ('13', True, True), - ('14', False, True), - ('15', False, True), - ]: - if consumer in workflow: - if needs_model: - workflow[consumer]['inputs']['model'] = list(model_source) - if needs_clip: - workflow[consumer]['inputs']['clip'] = list(clip_source) - - # 6. Fixed seed for all samplers - for seed_node in ['3', '11', '13']: - if seed_node in workflow: - workflow[seed_node]['inputs']['seed'] = int(fixed_seed) - - # 7. Apply checkpoint-specific settings (steps, cfg, sampler, base prompts, VAE) - if ckpt_data: - workflow = _apply_checkpoint_settings(workflow, ckpt_data) - - # 8. Sync sampler/scheduler to detailer nodes - sampler_name = workflow['3']['inputs'].get('sampler_name') - scheduler = workflow['3']['inputs'].get('scheduler') - for node_id in ['11', '13']: - if node_id in workflow: - if sampler_name: - workflow[node_id]['inputs']['sampler_name'] = sampler_name - if scheduler: - workflow[node_id]['inputs']['scheduler'] = scheduler - - # 9. Cross-dedup prompts - pos_text, neg_text = _cross_dedup_prompts( - workflow['6']['inputs']['text'], - workflow['7']['inputs']['text'] - ) - workflow['6']['inputs']['text'] = pos_text - workflow['7']['inputs']['text'] = neg_text - - _log_workflow_prompts(f"_prepare_strengths_workflow [node={active_node} lora={entity_lora_name} @ {strength_value} seed={fixed_seed}]", workflow) - return workflow - - -@app.route('/strengths///generate', methods=['POST']) -def strengths_generate(category, slug): - if category not in _STRENGTHS_MODEL_MAP: - return {'error': 'unknown category'}, 400 - - Model = _STRENGTHS_MODEL_MAP[category] - entity = Model.query.filter_by(slug=slug).first_or_404() - - try: - strength_value = float(request.form.get('strength_value', 1.0)) - fixed_seed = int(request.form.get('seed', random.randint(1, 10**15))) - - # Resolve character: prefer POST body value (reflects current page dropdown), - # then fall back to session. - # Session keys use the *singular* category name (char_outfit_, char_action_, …) - # but the URL uses the plural (outfits, actions, …). - _singular = { - 'outfits': 'outfit', 'actions': 'action', 'styles': 'style', - 'scenes': 'scene', 'detailers': 'detailer', 'looks': 'look', - } - session_prefix = _singular.get(category, category) - char_slug = (request.form.get('character_slug') or - session.get(f'char_{session_prefix}_{slug}')) - - if category == 'characters': - character = entity # entity IS the character - elif char_slug == '__random__': - character = Character.query.order_by(db.func.random()).first() - elif char_slug: - character = Character.query.filter_by(slug=char_slug).first() - else: - character = None - - print(f"[Strengths] char_slug={char_slug!r} → character={character.slug if character else 'none'}") - - # Read extra context that may be stored in session for some categories - action_obj = None - extra_positive = '' - extra_negative = '' - if category == 'detailers': - action_slug = session.get(f'action_detailer_{slug}') - if action_slug: - action_obj = Action.query.filter_by(slug=action_slug).first() - extra_positive = session.get(f'extra_pos_detailer_{slug}', '') - extra_negative = session.get(f'extra_neg_detailer_{slug}', '') - print(f"[Strengths] detailer session — char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}") - - prompts = _build_strengths_prompts(category, entity, character, - action=action_obj, extra_positive=extra_positive) - - checkpoint, ckpt_data = _get_default_checkpoint() - workflow_path = os.path.join(os.path.dirname(__file__), 'comfy_workflow.json') - with open(workflow_path, 'r') as f: - workflow = json.load(f) - - workflow = _prepare_strengths_workflow( - workflow, category, entity, character, prompts, - checkpoint, ckpt_data, strength_value, fixed_seed, - custom_negative=extra_negative - ) - - _category = category - _slug = slug - _strength_value = strength_value - _fixed_seed = fixed_seed - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id].get('outputs', {}) - img_data = None - for node_output in outputs.values(): - for img in node_output.get('images', []): - img_data = get_image(img['filename'], img.get('subfolder', ''), img.get('type', 'output')) - break - if img_data: - break - if not img_data: - raise Exception('no image in output') - strength_str = f"{_strength_value:.2f}".replace('.', '_') - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], _category, _slug, 'strengths') - os.makedirs(upload_dir, exist_ok=True) - out_filename = f"strength_{strength_str}_seed_{_fixed_seed}.png" - out_path = os.path.join(upload_dir, out_filename) - with open(out_path, 'wb') as f: - f.write(img_data) - relative = f"{_category}/{_slug}/strengths/{out_filename}" - job['result'] = {'image_url': f"/static/uploads/{relative}", 'strength_value': _strength_value} - - label = f"Strengths: {entity.name} @ {strength_value:.2f}" - job = _enqueue_job(label, workflow, _finalize) - return {'status': 'queued', 'job_id': job['id']} - - except Exception as e: - print(f"[Strengths] generate error: {e}") - return {'error': str(e)}, 500 - - -@app.route('/strengths///list') -def strengths_list(category, slug): - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], category, slug, 'strengths') - if not os.path.isdir(upload_dir): - return {'images': []} - - images = [] - for fname in sorted(os.listdir(upload_dir)): - if not fname.endswith('.png'): - continue - # Parse strength value from filename: strength_0_50_seed_12345.png → "0.50" - try: - parts = fname.replace('strength_', '').split('_seed_') - strength_raw = parts[0] # e.g. "0_50" - strength_display = strength_raw.replace('_', '.') - except Exception: - strength_display = fname - images.append({ - 'url': f"/static/uploads/{category}/{slug}/strengths/{fname}", - 'strength': strength_display, - 'filename': fname, - }) - return {'images': images} - - -@app.route('/strengths///clear', methods=['POST']) -def strengths_clear(category, slug): - upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], category, slug, 'strengths') - if os.path.isdir(upload_dir): - for fname in os.listdir(upload_dir): - fpath = os.path.join(upload_dir, fname) - if os.path.isfile(fpath): - os.remove(fpath) - return {'success': True} - - -_STRENGTHS_DATA_DIRS = { - 'characters': 'CHARACTERS_DIR', - 'looks': 'LOOKS_DIR', - 'outfits': 'CLOTHING_DIR', - 'actions': 'ACTIONS_DIR', - 'styles': 'STYLES_DIR', - 'scenes': 'SCENES_DIR', - 'detailers': 'DETAILERS_DIR', -} - - -@app.route('/strengths///save_range', methods=['POST']) -def strengths_save_range(category, slug): - """Save lora_weight_min / lora_weight_max from the Strengths Gallery back to the entity JSON + DB.""" - if category not in _STRENGTHS_MODEL_MAP or category not in _STRENGTHS_DATA_DIRS: - return {'error': 'unknown category'}, 400 - - try: - min_w = float(request.form.get('min_weight', '')) - max_w = float(request.form.get('max_weight', '')) - except (ValueError, TypeError): - return {'error': 'invalid weight values'}, 400 - - if min_w > max_w: - min_w, max_w = max_w, min_w - - Model = _STRENGTHS_MODEL_MAP[category] - entity = Model.query.filter_by(slug=slug).first_or_404() - - # Update in-memory data dict - data = dict(entity.data) - if 'lora' not in data or not isinstance(data.get('lora'), dict): - return {'error': 'entity has no lora section'}, 400 - - data['lora']['lora_weight_min'] = min_w - data['lora']['lora_weight_max'] = max_w - entity.data = data - flag_modified(entity, 'data') - - # Write back to JSON file on disk - data_dir = app.config[_STRENGTHS_DATA_DIRS[category]] - filename = getattr(entity, 'filename', None) or f"{slug}.json" - file_path = os.path.join(data_dir, filename) - if os.path.exists(file_path): - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') - - db.session.commit() - return {'success': True, 'lora_weight_min': min_w, 'lora_weight_max': max_w} - - if __name__ == '__main__': + from services.mcp import ensure_mcp_server_running, ensure_character_mcp_server_running + from services.job_queue import init_queue_worker + from services.sync import ( + sync_characters, sync_outfits, sync_actions, sync_styles, + sync_detailers, sync_scenes, sync_looks, sync_checkpoints, sync_presets, + ) + ensure_mcp_server_running() + ensure_character_mcp_server_running() + init_queue_worker(app) with app.app_context(): os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) db.create_all() - + # Migration: Add active_outfit column if it doesn't exist try: from sqlalchemy import text @@ -7261,7 +80,7 @@ if __name__ == '__main__': print("active_outfit column already exists") else: print(f"Migration note: {e}") - + # Migration: Add default_fields column to action table if it doesn't exist try: from sqlalchemy import text @@ -7298,13 +117,13 @@ if __name__ == '__main__': pass else: print(f"Migration settings note ({col_name}): {e}") - + # Ensure settings exist if not Settings.query.first(): db.session.add(Settings()) db.session.commit() print("Created default settings") - + # Log the default checkpoint on startup settings = Settings.query.first() if settings and settings.default_checkpoint: @@ -7313,7 +132,7 @@ if __name__ == '__main__': logger.info("=" * 80) else: logger.info("No default checkpoint set in database") - + sync_characters() sync_outfits() sync_actions() @@ -7334,4 +153,34 @@ if __name__ == '__main__': sync_looks() sync_checkpoints() sync_presets() + + # Migration: Convert look.character_id to look.character_ids + try: + from sqlalchemy import text + # First ensure the column exists + db.session.execute(text("ALTER TABLE look ADD COLUMN character_ids JSON")) + db.session.commit() + print("Added character_ids column to look table") + except Exception as e: + if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): + pass # Column already exists + else: + print(f"Migration note (character_ids column): {e}") + + # Migrate existing character_id to character_ids list + try: + looks_with_old_field = Look.query.filter(Look.character_id.isnot(None)).all() + migrated_count = 0 + for look in looks_with_old_field: + if not look.character_ids: + look.character_ids = [] + if look.character_id and look.character_id not in look.character_ids: + look.character_ids.append(look.character_id) + migrated_count += 1 + if migrated_count > 0: + db.session.commit() + print(f"Migrated {migrated_count} looks from character_id to character_ids") + except Exception as e: + print(f"Migration note (character_ids data): {e}") + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/data/actions/agressivechoking_000010.json b/data/actions/agressivechoking_000010.json deleted file mode 100644 index 15abb2a..0000000 --- a/data/actions/agressivechoking_000010.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "action_id": "agressivechoking_000010", - "action_name": "Agressivechoking 000010", - "action": { - "full_body": "dynamic perspective, leaning forward, dominant violent stance, POV", - "head": "face close to camera, angry expression, gritting teeth or shouting, heavy breathing", - "eyes": "intense stare, dilated pupils, furious gaze, sanpaku", - "arms": "extended towards viewer or subject, muscles tensed, shoulders shrugged forward", - "hands": "fingers curled tightly, hand around neck, strangling motion, squeezing", - "torso": "hunched forward, tense upper body", - "pelvis": "weight shifted forward", - "legs": "wide stance for leverage, braced", - "feet": "planted firmly", - "additional": "sweat, speed lines, depth of field, high contrast lighting, shadow over eyes" - }, - "participants": { - "solo_focus": "true", - "orientation": "MF" - }, - "lora": { - "lora_name": "Illustrious/Poses/AgressiveChoking-000010.safetensors", - "lora_weight": 1.0, - "lora_triggers": "AgressiveChoking-000010", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 - }, - "tags": [ - "violence", - "dominance", - "pov", - "combat", - "anger" - ] -} diff --git a/data/actions/ahegao_xl_v3_1278075.json b/data/actions/ahegao_xl_v3_1278075.json deleted file mode 100644 index 83986ba..0000000 --- a/data/actions/ahegao_xl_v3_1278075.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "action_id": "ahegao_xl_v3_1278075", - "action_name": "Ahegao Xl V3 1278075", - "action": { - "full_body": "portrait or upper body focus, emphasizing facial distortion", - "head": "tilted back slightly, mouth wide open, tongue hanging out, face heavily flushed", - "eyes": "rolled back upwards, cross-eyed, look of exhaustion or ecstasy", - "arms": "raised up near the head", - "hands": "making double peace signs (v-sign) framing the face", - "torso": "facing forward", - "pelvis": "neutral", - "legs": "neutral", - "feet": "not visible", - "additional": "saliva trail, drooling, sweat, heavy blush stickers, heart-shaped pupils" - }, - "participants": { - "solo_focus": "true", - "orientation": "F" - }, - "lora": { - "lora_name": "Illustrious/Poses/Ahegao_XL_v3_1278075.safetensors", - "lora_weight": 1.0, - "lora_triggers": "Ahegao_XL_v3_1278075", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 - }, - "tags": [ - "ahegao", - "rolling eyes", - "tongue out", - "open mouth", - "blush", - "drooling", - "saliva", - "cross-eyed", - "double peace sign", - "v-sign" - ] -} diff --git a/data/actions/before_after_1230829.json b/data/actions/before_after_1230829.json index 11f39db..594ba49 100644 --- a/data/actions/before_after_1230829.json +++ b/data/actions/before_after_1230829.json @@ -1,24 +1,24 @@ { + "action": { + "additional": "cum, close-uo", + "arms": "", + "eyes": "eyes_closed", + "feet": "", + "full_body": "2koma, before and after, side-by-side", + "hands": "", + "head": "sticky_face,facial, bukkake, cum_on_face", + "legs": "", + "pelvis": "", + "torso": "" + }, "action_id": "before_after_1230829", "action_name": "Before After 1230829", - "action": { - "full_body": "2koma, before_and_after", - "head": "heavy_breathing, orgasm, sticky_face", - "eyes": "eyes_closed", - "arms": "variation", - "hands": "variation", - "torso": "upper_body", - "pelvis": "variation", - "legs": "variation", - "feet": "variation", - "additional": "facial, bukkake, cum, cum_on_face" - }, "lora": { "lora_name": "Illustrious/Poses/before_after_1230829.safetensors", - "lora_weight": 0.9, "lora_triggers": "before_after", - "lora_weight_min": 0.9, - "lora_weight_max": 0.9 + "lora_weight": 0.9, + "lora_weight_max": 0.7, + "lora_weight_min": 0.6 }, "tags": [ "before_and_after", diff --git a/data/actions/bodybengirl.json b/data/actions/bodybengirl.json index 7cf2470..344b03a 100644 --- a/data/actions/bodybengirl.json +++ b/data/actions/bodybengirl.json @@ -2,21 +2,21 @@ "action_id": "bodybengirl", "action_name": "Bodybengirl", "action": { - "full_body": "suspended_congress, lifting_person, standing", + "full_body": "suspended_congress, lifting_person, dangling legs", "head": "", "eyes": "", - "arms": "reaching", + "arms": "dangling arms", "hands": "", "torso": "torso_grab, bent_over", "pelvis": "", - "legs": "legs_hanging", + "legs": "legs_hanging, ", "feet": "", - "additional": "1boy, 1girl, suspended" + "additional": "1boy, 1girl, suspended, size difference, loli" }, "lora": { "lora_name": "Illustrious/Poses/BodyBenGirl.safetensors", "lora_weight": 1.0, - "lora_triggers": "bentstand-front, bentstand-behind", + "lora_triggers": " bentstand-behind", "lora_weight_min": 1.0, "lora_weight_max": 1.0 }, @@ -29,4 +29,4 @@ "1boy", "1girl" ] -} +} \ No newline at end of file diff --git a/data/actions/butt_smother_ag_000043.json b/data/actions/butt_smother_ag_000043.json index 15d1c1c..3f0dc8e 100644 --- a/data/actions/butt_smother_ag_000043.json +++ b/data/actions/butt_smother_ag_000043.json @@ -2,7 +2,7 @@ "action_id": "butt_smother_ag_000043", "action_name": "Butt Smother Ag 000043", "action": { - "full_body": "facesitting, character sitting on face, pov from below, dominant pose", + "full_body": "1boy,1girl,facesitting, character sitting on face, pov from below, dominant pose", "head": "looking down at viewer, looking back over shoulder", "eyes": "looking at viewer, half-closed eyes, seductive gaze", "arms": "arms reaching back, supporting weight", @@ -34,4 +34,4 @@ "suffocation", "submissive view" ] -} +} \ No newline at end of file diff --git a/data/actions/buttjob.json b/data/actions/buttjob.json index cb55b8c..1f43cd8 100644 --- a/data/actions/buttjob.json +++ b/data/actions/buttjob.json @@ -2,16 +2,16 @@ "action_id": "buttjob", "action_name": "Buttjob", "action": { - "full_body": "bent over, back turned to viewer, kneeling or standing", - "head": "looking back over shoulder", - "eyes": "looking at viewer, half-closed", - "arms": "supporting upper body weight on cool surface or knees", - "hands": "resting on bed, knees or holding buttocks apart", - "torso": "arched back, leaning forward", - "pelvis": "pushed backward, hips elevated high", - "legs": "kneeling with thighs spread or standing bent", - "feet": "arched or plantar flexion", - "additional": "glutes pressed together, friction focus, skin indentation" + "full_body": "bent over, buttjob", + "head": "", + "eyes": "", + "arms": "", + "hands": "", + "torso": "", + "pelvis": "buttjob", + "legs": "", + "feet": "", + "additional": "" }, "participants": { "solo_focus": "true", @@ -26,12 +26,6 @@ }, "tags": [ "buttjob", - "back to viewer", - "bent over", - "arched back", - "kneeling", - "ass focus", - "glutes", - "between buttocks" + "butt" ] -} +} \ No newline at end of file diff --git a/data/actions/caught_masturbating_illustrious.json b/data/actions/caught_masturbating_illustrious.json deleted file mode 100644 index 40437fa..0000000 --- a/data/actions/caught_masturbating_illustrious.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "action_id": "caught_masturbating_illustrious", - "action_name": "Caught Masturbating Illustrious", - "action": { - "full_body": "standing in doorway, confronting viewer", - "head": "looking down or at viewer, surprised expression, blushing", - "eyes": "wide open, looking away or at penis", - "arms": "arms at sides or covering mouth", - "hands": "relaxed or raised in shock", - "torso": "facing viewer", - "pelvis": "standing straight", - "legs": "standing, legs together", - "feet": "standing on floor", - "additional": "male pov, male masturbation in foreground, open door background" - }, - "lora": { - "lora_name": "Illustrious/Poses/Caught_Masturbating_ILLUSTRIOUS.safetensors", - "lora_weight": 0.75, - "lora_triggers": "caught, male pov, male masturbation, girl walking in door, standing in doorway", - "lora_weight_min": 0.75, - "lora_weight_max": 0.75 - }, - "tags": [ - "pov", - "male_masturbation", - "penis", - "erection", - "walk-in", - "caught", - "doorway", - "open_door", - "standing", - "surprised", - "blush", - "looking_at_penis", - "looking_at_viewer", - "wide_shot", - "indoors" - ] -} diff --git a/data/actions/cheekbulge.json b/data/actions/cheekbulge.json index 034c0bd..fe5937a 100644 --- a/data/actions/cheekbulge.json +++ b/data/actions/cheekbulge.json @@ -3,7 +3,7 @@ "action_name": "Cheekbulge", "action": { "full_body": "fellatio", - "head": "cheek_bulge, head_tilt, saliva", + "head": "cheek_bulge, head_tilt, saliva, penis in mouth, fellatio", "eyes": "looking_up", "arms": "arms_behind_back", "hands": "hands_on_head", @@ -16,7 +16,7 @@ "lora": { "lora_name": "Illustrious/Poses/cheekbulge.safetensors", "lora_weight": 1.0, - "lora_triggers": "cheek bulge", + "lora_triggers": "cheek bulge, male pov", "lora_weight_min": 1.0, "lora_weight_max": 1.0 }, @@ -29,4 +29,4 @@ "penis", "pov" ] -} +} \ No newline at end of file diff --git a/data/actions/cof.json b/data/actions/cof.json index d38ef7d..ac78b5e 100644 --- a/data/actions/cof.json +++ b/data/actions/cof.json @@ -2,7 +2,7 @@ "action_id": "cof", "action_name": "Cum on Figure", "action": { - "full_body": "figurine, mini-girl", + "full_body": "figurine, mini-girl, cum on body, cum on figurine", "head": "", "eyes": "", "arms": "", @@ -11,7 +11,7 @@ "pelvis": "", "legs": "", "feet": "", - "additional": "cum, cum on body, excessive cum, cum on face, cum on breasts, cum on chest" + "additional": "cum,excessive cum," }, "participants": { "solo_focus": "true", @@ -25,11 +25,7 @@ "lora_weight_max": 1.0 }, "tags": [ - "standing force", - "carry on front", - "carry", - "lifting", - "legs wrapped", - "straddling" + "cum", + "figurine" ] -} +} \ No newline at end of file diff --git a/data/actions/disinterested_sex___bored_female.json b/data/actions/disinterested_sex___bored_female.json index d021cf7..f09034b 100644 --- a/data/actions/disinterested_sex___bored_female.json +++ b/data/actions/disinterested_sex___bored_female.json @@ -2,16 +2,16 @@ "action_id": "disinterested_sex___bored_female", "action_name": "Disinterested Sex Bored Female", "action": { - "full_body": "female lying on back, legs spread, passive body language, completely disengaged from implicit activity", - "head": "turned slightly or facing forward but focused on phone, resting on pillow", - "eyes": "looking at smartphone, dull gaze, half-closed, unenthusiastic", - "arms": "holding smartphone above face with one or both hands, elbows resting on surface", - "hands": "holding phone, scrolling on screen", - "torso": "lying flat, relaxed, exposed", - "pelvis": "hips passive, legs open", - "legs": "spread wide, knees bent, relaxed", - "feet": "loose, resting on bed", - "additional": "holding smartphone, checking phone, indifference, ignoring, nonchalant attitude" + "full_body": "1girl,hetero,doggystyle,faceless male, (solo focus:1.2)", + "head": "on stomach, resting on pillow", + "eyes": "looking at smartphone, bored", + "arms": "", + "hands": "holding phone", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "additional": "" }, "participants": { "solo_focus": "true", @@ -25,14 +25,6 @@ "lora_weight_max": 1.0 }, "tags": [ - "bored", - "disinterested", - "looking at phone", - "smartphone", - "lying", - "spread legs", - "passive", - "indifferent", - "expressionless" + "bored" ] -} +} \ No newline at end of file diff --git a/data/actions/dunking_face_in_a_bowl_of_cum_r1.json b/data/actions/dunking_face_in_a_bowl_of_cum_r1.json index 84d0974..571d78b 100644 --- a/data/actions/dunking_face_in_a_bowl_of_cum_r1.json +++ b/data/actions/dunking_face_in_a_bowl_of_cum_r1.json @@ -2,23 +2,23 @@ "action_id": "dunking_face_in_a_bowl_of_cum_r1", "action_name": "Dunking Face In A Bowl Of Cum R1", "action": { - "full_body": "leaning_forward, head_down, drowning", - "head": "face_down, air_bubble, crying, tears, embarrassed, disgust", - "eyes": "closed_eyes, tears", - "arms": "clutching_head, arms_up", - "hands": "clutching_head", - "torso": "leaning_forward", - "pelvis": "leaning_forward", - "legs": "standing", - "feet": "standing", - "additional": "bowl, cum" + "full_body": "kneeling, all fours, head_down, held down, close-up, from below, humiliation, (solo focus:1.2)", + "head": "face_down, cum in mouth, cum bubble, hand on anothers head, crying", + "eyes": "closed_eyes, ", + "arms": "", + "hands": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "additional": "cum bowl, " }, "lora": { "lora_name": "Illustrious/Poses/Dunking_face_in_a_bowl_of_cum_r1.safetensors", "lora_weight": 1.0, - "lora_triggers": "face in cum bowl, cum in bowl, cum bubble, excessive cum", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_triggers": "gokkun, cum bowl", + "lora_weight_min": 0.4, + "lora_weight_max": 0.6 }, "tags": [ "1girl", @@ -35,4 +35,4 @@ "bowl", "cum" ] -} +} \ No newline at end of file diff --git a/data/actions/facial_bukkake.json b/data/actions/facial_bukkake.json deleted file mode 100644 index 3878282..0000000 --- a/data/actions/facial_bukkake.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "action_id": "facial_bukkake", - "action_name": "Facial Bukkake", - "action": { - "full_body": "close-up portrait shot, focus primarily on the face and neck area", - "head": "tilted slightly backward, mouth open or tongue out, face heavily covered in white liquid", - "eyes": "closed or looking upward, eyelashes wet/clumped", - "arms": "out of frame or hands interacting with face/hair", - "hands": "holding hair back or wiping cheek", - "torso": "upper chest or shoulders visible, possibly stained", - "pelvis": "not visible", - "legs": "not visible", - "feet": "not visible", - "additional": "streaming white liquid, dripping, messy, wet skin texture, high viscosity" - }, - "participants": { - "solo_focus": "true", - "orientation": "F" - }, - "lora": { - "lora_name": "Illustrious/Poses/facial_bukkake.safetensors", - "lora_weight": 1.0, - "lora_triggers": "facial_bukkake", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 - }, - "tags": [ - "bukkake", - "facial", - "cum on face", - "semen", - "messy", - "white liquid", - "cum in eyes", - "cum in mouth", - "splatter", - "after sex" - ] -} diff --git a/data/actions/giantess_missionary_000037.json b/data/actions/giantess_missionary_000037.json index afe8023..91a81e5 100644 --- a/data/actions/giantess_missionary_000037.json +++ b/data/actions/giantess_missionary_000037.json @@ -2,15 +2,15 @@ "action_id": "giantess_missionary_000037", "action_name": "Giantess Missionary 000037", "action": { - "full_body": "missionary, lying, on_back, size_difference, giantess, larger_female", + "full_body": "1boy, 1girl, shota, onee-shota, missionary, lying, on_back, size_difference, giantess, larger_female, clothed female naked male", "head": "face_between_breasts, burying_face", - "eyes": "closed_eyes, expressionless", - "arms": "hug, arms_around_back", + "eyes": "closed_eyes, ", + "arms": "hug, hand on anothers head", "hands": "hands_on_back", - "torso": "breasts, cleavage, large_breasts", + "torso": "cleavage,", "pelvis": "hops", "legs": "spread_legs, legs_up", - "feet": "barefoot", + "feet": "", "additional": "male_on_top, hetero, bearhug, femdom" }, "lora": { @@ -22,7 +22,6 @@ }, "tags": [ "missionary", - "giantess", "size_difference", "larger_female", "face_between_breasts", @@ -32,4 +31,4 @@ "on_back", "cleavage" ] -} +} \ No newline at end of file diff --git a/data/actions/goblin_molestation_illustrious.json b/data/actions/goblin_molestation_illustrious.json index 5001812..ff4d3f3 100644 --- a/data/actions/goblin_molestation_illustrious.json +++ b/data/actions/goblin_molestation_illustrious.json @@ -1,24 +1,24 @@ { + "action": { + "additional": "size difference, bodily fluids, messy environment, cave background", + "arms": "restrained, held back,", + "eyes": "tearing, rolling back, distressed", + "feet": "", + "full_body": "1girl, surrounded, gangbang, torn clothing, shota, cum string, fellatio, irrumatio, captured, defeated", + "hands": "", + "head": "", + "legs": "", + "pelvis": "vaginal", + "torso": "exposed, pinned down, size difference" + }, "action_id": "goblin_molestation_illustrious", "action_name": "Goblin Molestation Illustrious", - "action": { - "full_body": "1girl surrounded by multiple small goblins in a gangbang scenario", - "head": "flustered, ahegao, or distressed expression", - "eyes": "tearing, rolling back, or heart-shaped pupils", - "arms": "restrained, held back, or grabbing sheets", - "hands": "clenched or grasped by goblins", - "torso": "exposed, pinned down, size difference emphasized", - "pelvis": "engaged in sexual activity, hips lifted", - "legs": "m-legs, spread wide, or held up by goblins", - "feet": "toes curled in pleasure or pain", - "additional": "size difference, bodily fluids, messy environment, cave background" - }, "lora": { "lora_name": "Illustrious/Poses/Goblin_Molestation_Illustrious.safetensors", + "lora_triggers": "Goblinestation, gangbang, multiple goblins, multiple boys, 1girl, sex, rape, violation, cave", "lora_weight": 0.8, - "lora_triggers": "Goblinestation, gangbang, many goblins, multiple boys, 1girl, sex", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_max": 0.8, + "lora_weight_min": 0.8 }, "tags": [ "1girl", @@ -32,4 +32,4 @@ "surrounded", "rape" ] -} +} \ No newline at end of file diff --git a/data/actions/goblin_molestation_illustrious_02.json b/data/actions/goblin_molestation_illustrious_02.json new file mode 100644 index 0000000..441ee0b --- /dev/null +++ b/data/actions/goblin_molestation_illustrious_02.json @@ -0,0 +1,34 @@ +{ + "action": { + "additional": "size difference, bodily fluids, messy environment, alley background, faceless male", + "arms": "restrained, held back,", + "eyes": "tearing, rolling back, distressed", + "feet": "", + "full_body": "1girl, surrounded, gangbang, torn clothing, (shota:1.5), cum string, fellatio, irrumatio, captured, defeated", + "hands": "", + "head": "hands on anothers head", + "legs": "", + "pelvis": "vaginal", + "torso": " size difference" + }, + "action_id": "goblin_molestation_illustrious_02", + "action_name": "Shota Molestation ", + "lora": { + "lora_name": "Illustrious/Poses/Goblin_Molestation_Illustrious.safetensors", + "lora_triggers": "Goblinestation, gangbang, multiple boys, 1girl, sex, rape, violation, alley", + "lora_weight": 0.8, + "lora_weight_max": 0.8, + "lora_weight_min": 0.8 + }, + "tags": [ + "1girl", + "multiple_boys", + "gangbang", + "group_sex", + "sex", + "cum", + "size_difference", + "surrounded", + "rape" + ] +} \ No newline at end of file diff --git a/data/characters/2b.json b/data/characters/2b.json new file mode 100644 index 0000000..d375d91 --- /dev/null +++ b/data/characters/2b.json @@ -0,0 +1,58 @@ +{ + "character_id": "2b", + "character_name": "2B", + "identity": { + "base_specs": "1girl, 2b_(nier:automata), pale_skin", + "hair": "short_hair, white_hair, bob_cut, bangs", + "eyes": "blue_eyes", + "hands": "white nails", + "arms": "", + "torso": "small breasts", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "" + }, + "wardrobe": { + "full_body": "black_dress, lace-trimmed_dress, gothic_lolita", + "headwear": " blindfold,", + "top": "black_dress, cleavage_cutout, feather_trim", + "bottom": "short_dress,", + "legwear": "thighhighs", + "footwear": "thigh_boots, black_boots, high_heels", + "hands": "black_gloves, ", + "accessories": "katana, sword_on_back" + }, + "styles": { + "aesthetic": "gothic_lolita, science_fiction, dark_atmosphere", + "primary_color": "black", + "secondary_color": "white", + "tertiary_color": "silver" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "2b_(nier:automata)" + }, + "tags": [ + "1girl", + "2b_(nier:automata)", + "short_hair", + "white_hair", + "bob_cut", + "blindfold", + "black_dress", + "gothic_lolita", + "thigh_boots", + "black_gloves", + "nier_automata", + "blue_eyes" + ] +} \ No newline at end of file diff --git a/data/characters/aisha_clan_clan.json b/data/characters/aisha_clan_clan.json new file mode 100644 index 0000000..12f6543 --- /dev/null +++ b/data/characters/aisha_clan_clan.json @@ -0,0 +1,49 @@ +{ + "character_id": "aisha_clan_clan", + "character_name": "Aisha Clan-Clan", + "identity": { + "base_specs": "1girl, dark_skin, toned, fangs, facial_mark", + "hair": "white_hair, long ears, single_braid, ring_hair_ornament, cat_ears, ", + "eyes": "aqua_eyes", + "hands": "claws", + "arms": "", + "torso": "abs, medium breasts", + "pelvis": "cat_tail,", + "legs": "", + "feet": "", + "extra": " circlet" + }, + "defaults": { + "expression": "grin", + "pose": "flexing bicep", + "scene": "space station" + }, + "wardrobe": { + "full_body": "off-shoulder_dress, two-tone_dress", + "headwear": "circlet, ring_hair_ornament", + "top": "neck_bell,white_collar, long_sleeves, cleavage", + "bottom": " black_belt,", + "legwear": "black_pantyhose, thigh_strap", + "footwear": "", + "hands": "bracelets", + "accessories": "" + }, + "styles": { + "aesthetic": "retro anime, outlaw_star", + "primary_color": "white", + "secondary_color": "green", + "tertiary_color": "black" + }, + "lora": { + "lora_name": "Illustrious/Looks/Hoseki_OutlawStar_AishaClanClan_IllustriousXL_v1.safetensors", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "ashcln" + }, + "tags": [ + "aisha_clanclan", + "outlaw_star", + "90's" + ] +} \ No newline at end of file diff --git a/data/characters/android_21.json b/data/characters/android_21.json new file mode 100644 index 0000000..24efe70 --- /dev/null +++ b/data/characters/android_21.json @@ -0,0 +1,57 @@ +{ + "character_id": "android_21", + "character_name": "Android 21", + "identity": { + "base_specs": "1girl, android_21, pale_skin", + "hair": "brown_hair, long_hair, big_hair, messy_hair", + "eyes": "blue_eyes, glasses", + "hands": "ring", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile", + "pose": "standing", + "scene": "indoors, laboratory" + }, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "lab_coat, red and blue checkered dress", + "bottom": "", + "legwear": "black_thighhighs", + "footwear": "high_heels", + "hands": "", + "accessories": "earrings, finger_ring" + }, + "styles": { + "aesthetic": "anime", + "primary_color": "white", + "secondary_color": "blue", + "tertiary_color": "red" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "android_21", + "1girl", + "long_hair", + "brown_hair", + "blue_eyes", + "glasses", + "lab_coat", + "checkered_dress", + "black_thighhighs", + "high_heels", + "dragon_ball" + ] +} \ No newline at end of file diff --git a/data/characters/becky_blackbell.json b/data/characters/becky_blackbell.json new file mode 100644 index 0000000..3a0fb9c --- /dev/null +++ b/data/characters/becky_blackbell.json @@ -0,0 +1,61 @@ +{ + "character_id": "becky_blackbell", + "character_name": "Becky Blackbell", + "identity": { + "base_specs": "becky_blackbell, 1girl, loli", + "hair": "brown_hair, short_hair, twintails", + "eyes": "brown_eyes", + "hands": "", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "" + }, + "wardrobe": { + "full_body": "eden_academy_school_uniform,gold_trim", + "headwear": "hair_ornament", + "top": " black_dress, ", + "bottom": "", + "legwear": "white_socks", + "footwear": "loafers", + "hands": "", + "accessories": "" + }, + "styles": { + "aesthetic": "anime_style", + "primary_color": "black", + "secondary_color": "gold", + "tertiary_color": "white" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "becky_blackbell", + "spy_x_family", + "1girl", + "solo", + "brown_hair", + "twintails", + "brown_eyes", + "eden_academy_school_uniform", + "black_dress", + "gold_trim", + "white_socks", + "loafers", + "hair_ornament", + "short_hair", + "child" + ] +} \ No newline at end of file diff --git a/data/characters/blossom_ppg.json b/data/characters/blossom_ppg.json new file mode 100644 index 0000000..a65c612 --- /dev/null +++ b/data/characters/blossom_ppg.json @@ -0,0 +1,61 @@ +{ + "character_id": "blossom_ppg", + "character_name": "Blossom", + "identity": { + "base_specs": "blossom_(ppg), 1girl, mature_female, slender, fair_skin", + "hair": "orange_hair, very_long_hair, high_ponytail, blunt_bangs", + "eyes": "pink_eyes, eyelashes", + "hands": "", + "arms": "", + "torso": "slender_waist", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile, confident", + "pose": "standing, hand_on_hip", + "scene": "city_skyline, day" + }, + "wardrobe": { + "full_body": "pink_dress, sleeveless_dress, A-line_dress", + "headwear": "red_bow, hair_bow", + "top": "", + "bottom": "", + "legwear": "white_leggings, white_tights", + "footwear": "black_footwear, mary_janes", + "hands": "", + "accessories": "black_belt" + }, + "styles": { + "aesthetic": "modern_cartoon, vibrant_colors", + "primary_color": "pink", + "secondary_color": "red", + "tertiary_color": "black" + }, + "lora": { + "lora_name": "Illustrious/Looks/Aged_up_Powerpuff_Girls.safetensors", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "1girl", + "mature_female", + "orange_hair", + "very_long_hair", + "high_ponytail", + "pink_eyes", + "hair_bow", + "red_bow", + "pink_dress", + "sleeveless_dress", + "black_belt", + "white_leggings", + "mary_janes", + "powerpuff_girls", + "aged_up" + ] +} \ No newline at end of file diff --git a/data/characters/bubbles_ppg.json b/data/characters/bubbles_ppg.json new file mode 100644 index 0000000..209b34a --- /dev/null +++ b/data/characters/bubbles_ppg.json @@ -0,0 +1,57 @@ +{ + "character_id": "bubbles_ppg", + "character_name": "Bubbles", + "identity": { + "base_specs": "bubbles_(ppg),1girl, aged_up, mature_female, slender", + "hair": "blonde_hair, short_hair, twintails", + "eyes": "blue_eyes, large_eyes", + "hands": "", + "arms": "", + "torso": "medium breasts", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smiling", + "pose": "standing", + "scene": "" + }, + "wardrobe": { + "full_body": "blue summer dress", + "headwear": "", + "top": "", + "bottom": " black_belt", + "legwear": "thigh high white socks", + "footwear": "black_footwear, mary_janes", + "hands": "", + "accessories": "" + }, + "styles": { + "aesthetic": "vibrant_colors", + "primary_color": "blue", + "secondary_color": "white", + "tertiary_color": "black" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "bubbles_(ppg)", + "1girl", + "aged_up", + "blonde_hair", + "twintails", + "blue_eyes", + "blue_dress", + "black_belt", + "white_socks", + "mary_janes", + "solo" + ] +} \ No newline at end of file diff --git a/data/characters/buttercup_ppg.json b/data/characters/buttercup_ppg.json new file mode 100644 index 0000000..f775c97 --- /dev/null +++ b/data/characters/buttercup_ppg.json @@ -0,0 +1,58 @@ +{ + "character_id": "buttercup_ppg", + "character_name": "Buttercup", + "identity": { + "base_specs": "1girl, buttercup_(ppg), aged_up, tomboy", + "hair": "black_hair, short_hair, bob_cut, flipped_hair", + "eyes": "green_eyes", + "hands": "", + "arms": "toned_arms", + "torso": "athletic_body, small_breasts", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile, smirk", + "pose": "looking_at_viewer, arms_crossed", + "scene": "" + }, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "green crop top, sleeveless, ", + "bottom": "black_belt, green shorts", + "legwear": "white knee high socks", + "footwear": "black army boots", + "hands": "fingerless leather gloves", + "accessories": "" + }, + "styles": { + "aesthetic": "vibrant_colors, high_contrast", + "primary_color": "green", + "secondary_color": "black", + "tertiary_color": "white" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "1girl", + "buttercup_(ppg)", + "aged_up", + "tomboy", + "short_hair", + "black_hair", + "bob_cut", + "green_eyes", + "green_dress", + "black_belt", + "white_leggings", + "mary_janes" + ] +} \ No newline at end of file diff --git a/data/characters/clover_totally_spies.json b/data/characters/clover_totally_spies.json new file mode 100644 index 0000000..196b347 --- /dev/null +++ b/data/characters/clover_totally_spies.json @@ -0,0 +1,56 @@ +{ + "character_id": "clover_totally_spies", + "character_name": "Clover", + "identity": { + "base_specs": "1girl, solo, slender", + "hair": "blonde_hair, medium_hair, bob_cut", + "eyes": "blue_eyes", + "hands": "", + "arms": "", + "torso": "small breasts", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile", + "pose": "standing", + "scene": "" + }, + "wardrobe": { + "full_body": "red_bodysuit, latex_bodysuit, spandex", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "high_heel_boots, boots", + "hands": "", + "accessories": "belt, silver_belt" + }, + "styles": { + "aesthetic": "anime_style, 2000s_aesthetic", + "primary_color": "red", + "secondary_color": "silver", + "tertiary_color": "blonde" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "clover_(totally_spies!)", + "totally_spies!", + "1girl", + "blonde_hair", + "blue_eyes", + "bob_cut", + "red_bodysuit", + "latex_bodysuit", + "high_heel_boots", + "silver_belt" + ] +} \ No newline at end of file diff --git a/data/characters/hikage_senran_kagura.json b/data/characters/hikage_senran_kagura.json new file mode 100644 index 0000000..d76667d --- /dev/null +++ b/data/characters/hikage_senran_kagura.json @@ -0,0 +1,66 @@ +{ + "character_id": "hikage_senran_kagura", + "character_name": "Hikage - Senran Kagura", + "identity": { + "base_specs": "1girl, mature_female, large_breasts, athletic_build, pale_skin", + "hair": "green_hair, short_hair, spiked_hair", + "eyes": "yellow_eyes, slit_pupils", + "hands": "fingernails", + "arms": "arm_belt, tattoo", + "torso": "chest_tattoo, stomach_tattoo, navel", + "pelvis": "open_fly", + "legs": "torn_jeans, leg_belt", + "feet": "boots", + "extra": "neck_tattoo, snake_print" + }, + "defaults": { + "expression": "stoic", + "pose": "standing", + "scene": "" + }, + "wardrobe": { + "full_body": "torn_clothes", + "headwear": "", + "top": "yellow_shirt, crop_top, torn_clothes", + "bottom": "torn_jeans, open_fly, loose_belt", + "legwear": "", + "footwear": "boots", + "hands": "arm_belt", + "accessories": "leg_belt" + }, + "styles": { + "aesthetic": "anime, video_game_character", + "primary_color": "yellow", + "secondary_color": "green", + "tertiary_color": "blue" + }, + "lora": { + "lora_name": "Illustrious/Looks/SK_Hikage_IL.safetensors", + "lora_weight": 0.8, + "lora_triggers": "Hikage_IL, Hikage_Shinobi", + "lora_weight_min": 0.8, + "lora_weight_max": 0.8 + }, + "tags": [ + "hikage_(senran_kagura)", + "green_hair", + "short_hair", + "yellow_eyes", + "slit_pupils", + "large_breasts", + "neck_tattoo", + "chest_tattoo", + "stomach_tattoo", + "yellow_shirt", + "crop_top", + "torn_clothes", + "torn_jeans", + "open_fly", + "loose_belt", + "snake_print", + "arm_belt", + "leg_belt", + "anime", + "video_game_character" + ] +} \ No newline at end of file diff --git a/data/characters/jasmine_disney.json b/data/characters/jasmine_disney.json index 6e881f1..fe42f40 100644 --- a/data/characters/jasmine_disney.json +++ b/data/characters/jasmine_disney.json @@ -37,7 +37,7 @@ "tertiary_color": "black" }, "lora": { - "lora_name": "Illustrious/Looks/Jasmine-IL_V2.safetensors", + "lora_name": "Illustrious/Looks/JasmineIL.safetensors", "lora_weight": 0.8, "lora_triggers": "", "lora_weight_min": 0.8, @@ -48,4 +48,4 @@ "princess", "disney" ] -} +} \ No newline at end of file diff --git a/data/characters/lara_croft_classic.json b/data/characters/lara_croft_classic.json index 381e766..dafd7b3 100644 --- a/data/characters/lara_croft_classic.json +++ b/data/characters/lara_croft_classic.json @@ -22,7 +22,7 @@ "default": { "full_body": "", "headwear": "", - "top": "teal tank top,", + "top": "teal leotard", "bottom": "brown shorts", "legwear": "thigh holsters", "footwear": "brown combat boots, red laces", @@ -46,4 +46,4 @@ "tags": [ "Tomb Raider" ] -} +} \ No newline at end of file diff --git a/data/characters/princess_bubblegum.json b/data/characters/princess_bubblegum.json new file mode 100644 index 0000000..efb3977 --- /dev/null +++ b/data/characters/princess_bubblegum.json @@ -0,0 +1,66 @@ +{ + "character_id": "princess_bubblegum", + "character_name": "Princess Bubblegum", + "identity": { + "base_specs": "1girl, princess_bonnibel_bubblegum, pink_skin,", + "hair": "pink_hair, long_hair, gum_hair", + "eyes": "black_eyes, dot_eyes, simple eyes", + "hands": "pink nails", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile", + "pose": "standing", + "scene": "candy_kingdom" + }, + "wardrobe": { + "default": { + "full_body": "pink_dress, ", + "headwear": "crown, gold_crown, blue_gemstone_on_crown, purple_collar, ", + "top": "puffy_sleeves", + "bottom": "purple belt", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "tiara" + }, + "scientist": { + "full_body": "", + "headwear": "crown, gold_crown, blue_gemstone_on_crown, tied hair, safety goggles", + "top": "white lab coat", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "purple cuffs", + "accessories": "tiara" + } + }, + "styles": { + "aesthetic": "adventure_time style,", + "primary_color": "pink", + "secondary_color": "purple", + "tertiary_color": "gold" + }, + "lora": { + "lora_name": "Illustrious/Looks/Bubblegum_ILL.safetensors", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "princess_bonnibel_bubblegum", + "adventure_time", + "pink_hair", + "pink_skin", + "pink_dress", + "crown", + "long_hair", + "cartoon_style" + ] +} \ No newline at end of file diff --git a/data/characters/princess_peach.json b/data/characters/princess_peach.json index a8ca18c..6e4714f 100644 --- a/data/characters/princess_peach.json +++ b/data/characters/princess_peach.json @@ -20,7 +20,7 @@ }, "wardrobe": { "default": { - "full_body": "pink ball gown", + "full_body": "pink gown", "headwear": "gold crown", "top": "white petticoat, puffy sleeves, dark pink panniers", "bottom": "", @@ -46,4 +46,4 @@ "tags": [ "Super Mario" ] -} +} \ No newline at end of file diff --git a/data/characters/shiki_senran_kagura.json b/data/characters/shiki_senran_kagura.json new file mode 100644 index 0000000..9c8f0a5 --- /dev/null +++ b/data/characters/shiki_senran_kagura.json @@ -0,0 +1,57 @@ +{ + "character_id": "shiki_senran_kagura", + "character_name": "Shiki", + "identity": { + "base_specs": "1girl, shiki_(senran_kagura), large_breasts, gyaru", + "hair": "blonde_hair, drill_hair, long_hair", + "eyes": "purple_eyes", + "hands": "", + "arms": "", + "torso": "cleavage", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "smile", + "pose": "", + "scene": "" + }, + "wardrobe": { + "full_body": "black_dress, frilled_dress, gothic_lolita", + "headwear": "black_hat, mini_hat", + "top": "", + "bottom": "", + "legwear": "black_thighhighs", + "footwear": "black_footwear", + "hands": "", + "accessories": "cross_necklace, scythe" + }, + "styles": { + "aesthetic": "gothic_lolita", + "primary_color": "black", + "secondary_color": "purple", + "tertiary_color": "gold" + }, + "lora": { + "lora_name": "Illustrious/Looks/shiki_kagura_ill_v01.safetensors", + "lora_weight": 1.0, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "shiki_(senran_kagura)" + }, + "tags": [ + "shiki_(senran_kagura)", + "senran_kagura", + "blonde_hair", + "drill_hair", + "purple_eyes", + "large_breasts", + "black_dress", + "mini_hat", + "thighhighs", + "cross_necklace", + "scythe" + ] +} \ No newline at end of file diff --git a/data/characters/starfire_teen_titans.json b/data/characters/starfire_teen_titans.json new file mode 100644 index 0000000..e90bed2 --- /dev/null +++ b/data/characters/starfire_teen_titans.json @@ -0,0 +1,64 @@ +{ + "character_id": "starfire_teen_titans", + "character_name": "Starfire - Teen Titans", + "identity": { + "base_specs": "1girl, tall, athletic", + "hair": "red_hair, long_hair", + "eyes": "green_eyes", + "hands": "", + "arms": "armlet, vambraces", + "torso": "small_breasts, crop_top", + "pelvis": "grey_belt", + "legs": "pencil_skirt", + "feet": "thigh_boots, purple_boots", + "extra": "gorget" + }, + "defaults": { + "expression": "smile", + "pose": "hovering", + "scene": "starry sky" + }, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "crop_top", + "bottom": "purple_skirt", + "legwear": "", + "footwear": "thigh_boots, purple_boots", + "hands": "vambraces", + "accessories": "gorget, grey_belt, armlet" + }, + "styles": { + "aesthetic": "cartoon, superhero", + "primary_color": "purple", + "secondary_color": "red", + "tertiary_color": "green" + }, + "lora": { + "lora_name": "Illustrious/Looks/Starfire IL.safetensors", + "lora_weight": 0.8, + "lora_triggers": "starfiredc", + "lora_weight_min": 0.8, + "lora_weight_max": 0.8 + }, + "tags": [ + "1girl", + "starfire", + "green_eyes", + "red_hair", + "long_hair", + "small_breasts", + "gorget", + "crop_top", + "armlet", + "pencil_skirt", + "purple_skirt", + "grey_belt", + "thigh_boots", + "vambraces", + "purple_boots", + "looking_at_viewer", + "smile", + "teen_titans" + ] +} \ No newline at end of file diff --git a/data/characters/yuffie_kisaragi.json b/data/characters/yuffie_kisaragi.json index 4569e9a..ed340a3 100644 --- a/data/characters/yuffie_kisaragi.json +++ b/data/characters/yuffie_kisaragi.json @@ -15,7 +15,7 @@ }, "defaults": { "expression": "cheeky smile", - "pose": "holding glass orb, materia", + "pose": "wave", "scene": "forest, sunlight" }, "wardrobe": { @@ -27,7 +27,7 @@ "legwear": "single kneehigh sock", "footwear": "boots, ", "hands": "fingerless glove on one hand, large gauntlet on one arm", - "accessories": "shuriken" + "accessories": "shuriken, materia" } }, "styles": { @@ -46,4 +46,4 @@ "tags": [ "Final Fantasy VII" ] -} +} \ No newline at end of file diff --git a/data/checkpoints/illustrious_beretmixreal_v80.json b/data/checkpoints/illustrious_beretmixreal_v80.json index 02dcc14..cf4f86a 100644 --- a/data/checkpoints/illustrious_beretmixreal_v80.json +++ b/data/checkpoints/illustrious_beretmixreal_v80.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/beretMixReal_v80.safetensors", + "base_negative": "worst quality, low quality, normal quality, watermark, sexual fluids, loli", + "base_positive": "masterpiece, best quality, photo realistic, ultra detailed, realistic skin, ultra high res, 8k, very aesthetic, absurdres,", + "cfg": 7, "checkpoint_name": "beretMixReal_v80.safetensors", - "base_positive": "masterpiece, best quality, photo realistic, ultra detailed, realistic skin, ultra high res, 8k, very aesthetic, absurdres", - "base_negative": "worst quality, low quality, normal quality, watermark, sexual fluids", - "steps": 30, - "cfg": 7.0, + "checkpoint_path": "Illustrious/beretMixReal_v80.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", + "steps": 30, "vae": "integrated" } \ No newline at end of file diff --git a/data/checkpoints/illustrious_catpony_aniilv51.json b/data/checkpoints/illustrious_catpony_aniilv51.json index 2538460..f7adeb0 100644 --- a/data/checkpoints/illustrious_catpony_aniilv51.json +++ b/data/checkpoints/illustrious_catpony_aniilv51.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/catpony_aniIlV51.safetensors", - "checkpoint_name": "catpony_aniIlV51.safetensors", - "base_positive": "masterpiece, best quality, 2.5D, very aesthetic, absurdres", "base_negative": "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, artist name", - "steps": 60, - "cfg": 3.0, + "base_positive": "masterpiece, best quality, 2.5D, very aesthetic, absurdres", + "cfg": 3, + "checkpoint_name": "catpony_aniIlV51.safetensors", + "checkpoint_path": "Illustrious/catpony_aniIlV51.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", - "vae": "integrated" + "steps": 60, + "vae": "sdxl_vae.safetensors" } \ No newline at end of file diff --git a/data/checkpoints/illustrious_cutecandymix_illustrious.json b/data/checkpoints/illustrious_cutecandymix_illustrious.json index d1430e4..f024b4d 100644 --- a/data/checkpoints/illustrious_cutecandymix_illustrious.json +++ b/data/checkpoints/illustrious_cutecandymix_illustrious.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/cutecandymix_illustrious.safetensors", - "checkpoint_name": "cutecandymix_illustrious.safetensors", - "base_positive": "masterpiece, best quality, very aesthetic, absurdres, year 2023", "base_negative": "lowres, (bad), text, error, fewer, extra, missing, worst quality, jpeg artifacts, low quality, watermark, unfinished, displeasing, oldest, early, chromatic aberration, signature, extra digits, artistic error, username, scan, (abstract:0.9), ", - "steps": 28, - "cfg": 5.0, + "base_positive": "masterpiece, best quality, very aesthetic, absurdres, loli", + "cfg": 5, + "checkpoint_name": "cutecandymix_illustrious.safetensors", + "checkpoint_path": "Illustrious/cutecandymix_illustrious.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", + "steps": 28, "vae": "sdxl_vae.safetensors" } \ No newline at end of file diff --git a/data/checkpoints/illustrious_kawaiialluxanime.json b/data/checkpoints/illustrious_kawaiialluxanime.json index c922f6c..44a471d 100644 --- a/data/checkpoints/illustrious_kawaiialluxanime.json +++ b/data/checkpoints/illustrious_kawaiialluxanime.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/kawaiialluxanime_.safetensors", - "checkpoint_name": "kawaiialluxanime_.safetensors", - "base_positive": "masterpiece, best quality, absurdres, amazing quality, intricate details", "base_negative": "lowres, worst quality, low quality, bad anatomy, bad hand, extra digits, ", - "steps": 25, - "cfg": 5.0, + "base_positive": "masterpiece, best quality, absurdres, amazing quality, intricate details", + "cfg": 5, + "checkpoint_name": "kawaiialluxanime_.safetensors", + "checkpoint_path": "Illustrious/kawaiialluxanime_.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", - "vae": "integrated" + "steps": 25, + "vae": "sdxl_vae.safetensors" } \ No newline at end of file diff --git a/data/checkpoints/illustrious_perfectdeliberate_v60.json b/data/checkpoints/illustrious_perfectdeliberate_v60.json index 427bcd9..b480bb1 100644 --- a/data/checkpoints/illustrious_perfectdeliberate_v60.json +++ b/data/checkpoints/illustrious_perfectdeliberate_v60.json @@ -1,11 +1,11 @@ { - "checkpoint_path": "Illustrious/perfectdeliberate_v60.safetensors", - "checkpoint_name": "perfectdeliberate_v60.safetensors", - "base_positive": "masterpiece, best quality, newest, absurdres, highres, 8K, ultra-detailed, realistic lighting", "base_negative": "lowres, worst quality, bad quality, modern, recent, oldest, signature, username, logo, watermark, jpeg artifacts, bad hands, cropped, missing fingers, extra digits, fewer digits, error, bad anatomy, ugly, disfigured, young, long neck", - "steps": 28, - "cfg": 5.0, + "base_positive": "masterpiece, best quality, newest, absurdres, highres, 8K, ultra-detailed, realistic lighting", + "cfg": 5, + "checkpoint_name": "perfectdeliberate_v60.safetensors", + "checkpoint_path": "Illustrious/perfectdeliberate_v60.safetensors", + "sampler_name": "euler_ancestral", "scheduler": "normal", - "sampler_name": "euler_ancestral", - "vae": "integrated" + "steps": 28, + "vae": "sdxl_vae.safetensors" } \ No newline at end of file diff --git a/data/clothing/ahsmaidill.json b/data/clothing/ahsmaidill.json index ad3b109..6f06210 100644 --- a/data/clothing/ahsmaidill.json +++ b/data/clothing/ahsmaidill.json @@ -15,7 +15,7 @@ "lora_name": "Illustrious/Clothing/AHSMaidILL.safetensors", "lora_weight": 0.8, "lora_triggers": "AHSMaidILL", - "lora_weight_min": 0.8, + "lora_weight_min": 0.6, "lora_weight_max": 0.8 }, "tags": [ diff --git a/data/clothing/bikini_02.json b/data/clothing/bikini_02.json index d19b7ac..249b0ff 100644 --- a/data/clothing/bikini_02.json +++ b/data/clothing/bikini_02.json @@ -1,10 +1,10 @@ { - "outfit_id": "bikini_02", - "outfit_name": "Bikini (Slingshot)", + "outfit_id": "swimsuit_slingshot", + "outfit_name": "Swimsuit - Slingshot", "wardrobe": { - "full_body": "", + "full_body": "slingshot swimsuit", "headwear": "", - "top": "slingshot swimsuit", + "top": "", "bottom": "", "legwear": "", "footwear": "", @@ -25,4 +25,4 @@ "navel", "revealing clothes" ] -} +} \ No newline at end of file diff --git a/data/clothing/bitch_illustrious_v1_0.json b/data/clothing/bitch_illustrious_v1_0.json index 8ad0806..148611c 100644 --- a/data/clothing/bitch_illustrious_v1_0.json +++ b/data/clothing/bitch_illustrious_v1_0.json @@ -7,7 +7,7 @@ "lora_weight_max": 0.8 }, "outfit_id": "bitch_illustrious_v1_0", - "outfit_name": "Bitch Illustrious V1 0", + "outfit_name": "Gyaru - Bitch Style", "tags": [ "gyaru", "jewelry", @@ -25,4 +25,4 @@ "legwear": "", "top": "" } -} +} \ No newline at end of file diff --git a/data/clothing/boundbeltedlatexnurseill.json b/data/clothing/boundbeltedlatexnurseill.json index c7eb56f..5f60914 100644 --- a/data/clothing/boundbeltedlatexnurseill.json +++ b/data/clothing/boundbeltedlatexnurseill.json @@ -1,28 +1,25 @@ { "outfit_id": "boundbeltedlatexnurseill", - "outfit_name": "Boundbeltedlatexnurseill", + "outfit_name": "Latex Nurse - Breast Belt", "wardrobe": { "full_body": "latex_dress", "headwear": "nurse_cap, mouth mask", - "top": "", - "bottom": "", - "legwear": "", - "footwear": "", + "top": "(belt across breasts:1.2)", + "bottom": "latex skirt", + "legwear": "lace stockings", + "footwear": "latex thigh boots, high heels", "hands": "elbow_gloves", - "accessories": "surgical_mask, belt, harness" + "accessories": "" }, "lora": { "lora_name": "Illustrious/Clothing/BoundBeltedLatexNurseILL.safetensors", "lora_weight": 0.8, - "lora_triggers": "nurse_cap, latex_dress, elbow_gloves, surgical_mask, underboob_cutout", - "lora_weight_min": 0.8, + "lora_triggers": "latex nurse", + "lora_weight_min": 0.6, "lora_weight_max": 0.8 }, "tags": [ "nurse", - "latex", - "underboob_cutout", - "bondage", - "clothing" + "latex" ] -} +} \ No newline at end of file diff --git a/data/clothing/cafecutiemaidill.json b/data/clothing/cafecutiemaidill.json index 5fe545b..4abf63d 100644 --- a/data/clothing/cafecutiemaidill.json +++ b/data/clothing/cafecutiemaidill.json @@ -15,8 +15,8 @@ "lora_name": "Illustrious/Clothing/CafeCutieMaidILL.safetensors", "lora_weight": 0.8, "lora_triggers": "CafeCutieMaidILL", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_min": 0.2, + "lora_weight_max": 0.6 }, "tags": [ "maid_dress", diff --git a/data/clothing/cageddemonsunderbustdressill.json b/data/clothing/cageddemonsunderbustdressill.json index e0eccb0..429c718 100644 --- a/data/clothing/cageddemonsunderbustdressill.json +++ b/data/clothing/cageddemonsunderbustdressill.json @@ -2,9 +2,9 @@ "outfit_id": "cageddemonsunderbustdressill", "outfit_name": "Cageddemonsunderbustdressill", "wardrobe": { - "full_body": "latex_dress, underbust", + "full_body": "latex_dress", "headwear": "", - "top": "pasties", + "top": "pasties, underbust", "bottom": "", "legwear": "", "footwear": "", @@ -15,7 +15,7 @@ "lora_name": "Illustrious/Clothing/CagedDemonsUnderbustDressILL.safetensors", "lora_weight": 0.8, "lora_triggers": "CagedDemonsUnderbustDressILL", - "lora_weight_min": 0.8, + "lora_weight_min": 0.2, "lora_weight_max": 0.8 }, "tags": [ diff --git a/data/clothing/candycanelatexlingerieill.json b/data/clothing/candycanelatexlingerieill.json new file mode 100644 index 0000000..c8602aa --- /dev/null +++ b/data/clothing/candycanelatexlingerieill.json @@ -0,0 +1,33 @@ +{ + "outfit_id": "candycanelatexlingerieill", + "outfit_name": "Candy Cane Latex Lingerie", + "wardrobe": { + "full_body": "red_capelet, latex_lingerie", + "headwear": "", + "top": "red_capelet, latex_bra", + "bottom": "latex_panties, garter_belt", + "legwear": "striped_thighhighs", + "footwear": "high_heels", + "hands": "", + "accessories": "candy_cane" + }, + "lora": { + "lora_name": "Illustrious/Clothing/candycanelatexlingerieILL.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "latex", + "lingerie", + "red_capelet", + "striped_thighhighs", + "high_heels", + "garter_belt", + "panties", + "striped_clothes", + "candy_cane", + "shiny" + ] +} \ No newline at end of file diff --git a/data/clothing/checkingitouthaltertopill.json b/data/clothing/checkingitouthaltertopill.json index a226590..bbb94fb 100644 --- a/data/clothing/checkingitouthaltertopill.json +++ b/data/clothing/checkingitouthaltertopill.json @@ -15,8 +15,8 @@ "lora_name": "Illustrious/Clothing/CheckingItOutHalterTopILL.safetensors", "lora_weight": 0.8, "lora_triggers": "CheckingItOutHalterTopILL", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_min": 0.2, + "lora_weight_max": 0.6 }, "tags": [ "blue", diff --git a/data/clothing/extra_microskirt_xl_illustrious_v1_0.json b/data/clothing/extra_microskirt_xl_illustrious_v1_0.json index d2e082a..7ce862a 100644 --- a/data/clothing/extra_microskirt_xl_illustrious_v1_0.json +++ b/data/clothing/extra_microskirt_xl_illustrious_v1_0.json @@ -4,7 +4,7 @@ "wardrobe": { "full_body": "", "headwear": "", - "top": "shirt", + "top": "", "bottom": "microskirt", "legwear": "", "footwear": "", @@ -24,4 +24,4 @@ "shirt", "suit" ] -} +} \ No newline at end of file diff --git a/data/clothing/flower_000001_1563226.json b/data/clothing/flower_000001_1563226.json index 6f62bfe..24600f5 100644 --- a/data/clothing/flower_000001_1563226.json +++ b/data/clothing/flower_000001_1563226.json @@ -1,22 +1,22 @@ { "outfit_id": "flower_000001_1563226", - "outfit_name": "Flower 000001 1563226", + "outfit_name": "Swimsuit - Flower", "wardrobe": { "full_body": "", "headwear": "", - "top": "bandeau", - "bottom": "high-waist_bikini", + "top": "flower bikini top", + "bottom": "flower bikini bottom", "legwear": "", "footwear": "", - "hands": "detached_sleeves", - "accessories": "fur_choker" + "hands": "", + "accessories": "" }, "lora": { "lora_name": "Illustrious/Clothing/Flower-000001_1563226.safetensors", "lora_weight": 0.8, "lora_triggers": "Jedpslb", "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_max": 1.0 }, "tags": [ "bikini", @@ -25,4 +25,4 @@ "animal_ears", "tail" ] -} +} \ No newline at end of file diff --git a/data/clothing/french_maid_01.json b/data/clothing/french_maid_01.json index ce43955..4d0fae3 100644 --- a/data/clothing/french_maid_01.json +++ b/data/clothing/french_maid_01.json @@ -15,8 +15,8 @@ "lora_name": "Illustrious/Clothing/V2_Latex_Maid_Illustrious.safetensors", "lora_weight": 0.8, "lora_triggers": "", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_min": 0.4, + "lora_weight_max": 1.0 }, "tags": [ "French Maid" diff --git a/data/clothing/french_maid_02.json b/data/clothing/french_maid_02.json index 63fda7f..3415f61 100644 --- a/data/clothing/french_maid_02.json +++ b/data/clothing/french_maid_02.json @@ -2,7 +2,7 @@ "outfit_id": "french_maid_02", "outfit_name": "French Maid (Latex)", "wardrobe": { - "full_body": "", + "full_body": "latex maid", "headwear": "hairband", "top": "corset, low cut top", "bottom": "frilled skirt", @@ -22,4 +22,4 @@ "French Maid", "latex" ] -} +} \ No newline at end of file diff --git a/data/clothing/goth_girl_ill.json b/data/clothing/goth_girl_ill.json index c12c104..225447a 100644 --- a/data/clothing/goth_girl_ill.json +++ b/data/clothing/goth_girl_ill.json @@ -2,13 +2,13 @@ "outfit_id": "goth_girl_ill", "outfit_name": "Goth Girl Ill", "wardrobe": { - "full_body": "", - "headwear": "hood", + "full_body": "goth", + "headwear": "", "top": "corset", - "bottom": "skirt", - "legwear": "thighhighs", - "footwear": "", - "hands": "", + "bottom": "lace skirt", + "legwear": "fishnet pantyhose", + "footwear": "lace up boots", + "hands": "fishnet elbow gloves", "accessories": "choker, jewelry" }, "lora": { @@ -23,7 +23,6 @@ "makeup", "black_lips", "black_nails", - "eyeshadow", - "pale_skin" + "eyeshadow" ] -} +} \ No newline at end of file diff --git a/data/clothing/idolswimsuitil_1665226.json b/data/clothing/idolswimsuitil_1665226.json deleted file mode 100644 index 977ab5a..0000000 --- a/data/clothing/idolswimsuitil_1665226.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "outfit_id": "idolswimsuitil_1665226", - "outfit_name": "Idolswimsuitil 1665226", - "wardrobe": { - "full_body": "bikini", - "headwear": "", - "top": "bandeau", - "bottom": "high-waist_bottom", - "legwear": "", - "footwear": "", - "hands": "", - "accessories": "" - }, - "lora": { - "lora_name": "Illustrious/Clothing/IdolSwimsuitIL_1665226.safetensors", - "lora_weight": 1.0, - "lora_triggers": "Jedpslb", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 - }, - "tags": [ - "pastel_colors", - "high-waist_bikini", - "strapless", - "bare_shoulders", - "navel" - ] -} diff --git a/data/clothing/laceskimpyleotardill.json b/data/clothing/laceskimpyleotardill.json index 3a6be21..1a40ef2 100644 --- a/data/clothing/laceskimpyleotardill.json +++ b/data/clothing/laceskimpyleotardill.json @@ -1,12 +1,12 @@ { "outfit_id": "laceskimpyleotardill", - "outfit_name": "Laceskimpyleotardill", + "outfit_name": "Lingerie - Lace Leotard", "wardrobe": { - "full_body": "leotard", - "headwear": "blindfold", + "full_body": "lace leotard", + "headwear": "", "top": "", "bottom": "", - "legwear": "fishnet_thighhighs", + "legwear": "lace stockings", "footwear": "", "hands": "", "accessories": "" @@ -23,4 +23,4 @@ "lingerie", "clothing_cutout" ] -} +} \ No newline at end of file diff --git a/data/clothing/latexnurseill.json b/data/clothing/latexnurseill.json index 37acd60..430fb82 100644 --- a/data/clothing/latexnurseill.json +++ b/data/clothing/latexnurseill.json @@ -15,8 +15,8 @@ "lora_name": "Illustrious/Clothing/LatexNurseILL.safetensors", "lora_weight": 0.7, "lora_triggers": "latex nurse, nurse cap, red skirt, midriff, cleavage", - "lora_weight_min": 0.7, - "lora_weight_max": 0.7 + "lora_weight_min": 0.4, + "lora_weight_max": 0.8 }, "tags": [ "nurse", diff --git a/data/clothing/oilslickdressill.json b/data/clothing/oilslickdressill.json index 0f32c0b..8630057 100644 --- a/data/clothing/oilslickdressill.json +++ b/data/clothing/oilslickdressill.json @@ -15,7 +15,7 @@ "lora_name": "Illustrious/Clothing/OilSlickDressILL.safetensors", "lora_weight": 0.8, "lora_triggers": "black metallic dress, side slit, oil slick", - "lora_weight_min": 0.8, + "lora_weight_min": 0.2, "lora_weight_max": 0.8 }, "tags": [ diff --git a/data/clothing/tifalockhartff7advchilcasual_illu_dwnsty_000006.json b/data/clothing/tifalockhartff7advchilcasual_illu_dwnsty_000006.json new file mode 100644 index 0000000..d9a4621 --- /dev/null +++ b/data/clothing/tifalockhartff7advchilcasual_illu_dwnsty_000006.json @@ -0,0 +1,32 @@ +{ + "outfit_id": "tifalockhartff7advchilcasual_illu_dwnsty_000006", + "outfit_name": "Cosplay - Tifa Advent Children", + "wardrobe": { + "full_body": "cosplay", + "headwear": "", + "top": "black_vest", + "bottom": "black_shorts", + "legwear": "", + "footwear": "black_boots", + "hands": "fingerless_gloves", + "accessories": "arm_ribbon, pink_ribbon" + }, + "lora": { + "lora_name": "Illustrious/Clothing/TifaLockhartFF7AdvChilCasual_Illu_Dwnsty-000006.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "cosplay", + "black_vest", + "black_shorts", + "fingerless_gloves", + "arm_ribbon", + "pink_ribbon", + "black_boots", + "midriff", + "navel" + ] +} \ No newline at end of file diff --git a/data/clothing/tifalockhartff7advchilfeather_illu_dwnsty_000006.json b/data/clothing/tifalockhartff7advchilfeather_illu_dwnsty_000006.json new file mode 100644 index 0000000..18531b8 --- /dev/null +++ b/data/clothing/tifalockhartff7advchilfeather_illu_dwnsty_000006.json @@ -0,0 +1,31 @@ +{ + "outfit_id": "tifalockhartff7advchilfeather_illu_dwnsty_000006", + "outfit_name": "Cosplay - Tifa Feather Dress", + "wardrobe": { + "full_body": "black_dress, strapless_dress, feather_trim", + "headwear": "feather_hair_ornament", + "top": "", + "bottom": "", + "legwear": "thighhighs", + "footwear": "", + "hands": "black_gloves, detached_sleeves", + "accessories": "black_feathers" + }, + "lora": { + "lora_name": "Illustrious/Clothing/TifaLockhartFF7AdvChilFeather_Illu_Dwnsty-000006.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "black_dress", + "feathers", + "black_feathers", + "detached_sleeves", + "black_gloves", + "thighhighs", + "feather_trim", + "strapless_dress" + ] +} \ No newline at end of file diff --git a/data/clothing/tifalockhartff7amarantsguise_illu_dwnsty_000008.json b/data/clothing/tifalockhartff7amarantsguise_illu_dwnsty_000008.json new file mode 100644 index 0000000..5ce8605 --- /dev/null +++ b/data/clothing/tifalockhartff7amarantsguise_illu_dwnsty_000008.json @@ -0,0 +1,34 @@ +{ + "outfit_id": "tifalockhartff7amarantsguise_illu_dwnsty_000008", + "outfit_name": "Cosplay - Tifa Amarant", + "wardrobe": { + "full_body": "red vest and white pants", + "headwear": "", + "top": "red vest, sleeveless, midriff", + "bottom": "white pants", + "legwear": "", + "footwear": "", + "hands": "red gloves, fingerless gloves", + "accessories": "jewelry, bangle" + }, + "lora": { + "lora_name": "Illustrious/Clothing/TifaLockhartFF7AmarantsGuise_Illu_Dwnsty-000008.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.8, + "lora_weight_max": 0.8, + "lora_triggers": "amarants guise, amarantstflckhrt" + }, + "tags": [ + "tifa_lockhart", + "final_fantasy_vii:_ever_crisis", + "red_vest", + "white_pants", + "midriff", + "navel", + "red_gloves", + "fingerless_gloves", + "sleeveless", + "jewelry", + "bangle" + ] +} \ No newline at end of file diff --git a/data/clothing/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json b/data/clothing/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json new file mode 100644 index 0000000..9fb8dbe --- /dev/null +++ b/data/clothing/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json @@ -0,0 +1,31 @@ +{ + "outfit_id": "tifalockhartff7bahamutsuit_illu_dwnsty_000006", + "outfit_name": "Cosplay - Tifa Bahamut", + "wardrobe": { + "full_body": "black_bodysuit, black_armor, navel_cutout", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "thighhighs", + "footwear": "", + "hands": "gauntlets", + "accessories": "mechanical_wings, dragon_wings" + }, + "lora": { + "lora_name": "Illustrious/Clothing/TifaLockhartFF7BahamutSuit_Illu_Dwnsty-000006.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "tifa_lockhart_(bahamut_suit)", + "black_bodysuit", + "black_armor", + "navel_cutout", + "thighhighs", + "gauntlets", + "mechanical_wings", + "dragon_wings" + ] +} \ No newline at end of file diff --git a/data/clothing/tifalockhartff7bunnybustier_illu_dwnsty.json b/data/clothing/tifalockhartff7bunnybustier_illu_dwnsty.json new file mode 100644 index 0000000..8c662c4 --- /dev/null +++ b/data/clothing/tifalockhartff7bunnybustier_illu_dwnsty.json @@ -0,0 +1,33 @@ +{ + "outfit_id": "tifalockhartff7bunnybustier_illu_dwnsty", + "outfit_name": "Cosplay - Tifa Bunny", + "wardrobe": { + "full_body": "playboy_bunny", + "headwear": "rabbit_ears", + "top": "black_bustier", + "bottom": "", + "legwear": "fishnet_pantyhose", + "footwear": "high_heels", + "hands": "fingerless_gloves", + "accessories": "rabbit_tail, wrist_cuffs, bowtie, white_collar" + }, + "lora": { + "lora_name": "Illustrious/Clothing/TifaLockhartFF7BunnyBustier_Illu_Dwnsty.safetensors", + "lora_weight": 0.8, + "lora_weight_min": 0.7, + "lora_weight_max": 1.0, + "lora_triggers": "" + }, + "tags": [ + "tifa_lockhart_(bunny_bustier)", + "playboy_bunny", + "rabbit_ears", + "black_bustier", + "fishnet_pantyhose", + "wrist_cuffs", + "bowtie", + "fingerless_gloves", + "rabbit_tail", + "white_collar" + ] +} \ No newline at end of file diff --git a/data/detailers/cutepussy_000006.json b/data/detailers/cutepussy_000006.json index f830679..f681a2f 100644 --- a/data/detailers/cutepussy_000006.json +++ b/data/detailers/cutepussy_000006.json @@ -1,12 +1,13 @@ { "detailer_id": "cutepussy_000006", "detailer_name": "Cutepussy 000006", - "prompt": "pussy, spread_pussy, urethra, close-up, between_legs, uncensored", + "prompt": "clitoris, pussy, spread_pussy, urethra, close-up, between_legs, uncensored", "lora": { "lora_name": "Illustrious/Detailers/cutepussy-000006.safetensors", "lora_weight": 1.15, "lora_weight_min": 0.8, "lora_weight_max": 1.5, "lora_triggers": "cutepussy" - } + }, + "tags": [] } \ No newline at end of file diff --git a/data/detailers/sts_age_slider_illustrious_v1.json b/data/detailers/sts_age_slider_illustrious_v1.json index dbceceb..6a8a72d 100644 --- a/data/detailers/sts_age_slider_illustrious_v1.json +++ b/data/detailers/sts_age_slider_illustrious_v1.json @@ -1,12 +1,12 @@ { "detailer_id": "sts_age_slider_illustrious_v1", "detailer_name": "Sts Age Slider Illustrious V1", - "prompt": "mature_female, petite, aged_up, aged_down", "lora": { "lora_name": "Illustrious/Detailers/StS_Age_Slider_Illustrious_v1.safetensors", - "lora_weight": 1.0, - "lora_weight_min": -5.0, - "lora_weight_max": 5.0, - "lora_triggers": "StS_Age_Slider_Illustrious_v1" - } + "lora_triggers": "StS_Age_Slider_Illustrious_v1", + "lora_weight": 1, + "lora_weight_max": -5, + "lora_weight_min": -5 + }, + "prompt": "loli, child, young, small" } \ No newline at end of file diff --git a/data/detailers/xtrasmol_000019_1595565.json b/data/detailers/xtrasmol_000019_1595565.json new file mode 100644 index 0000000..83d2f7a --- /dev/null +++ b/data/detailers/xtrasmol_000019_1595565.json @@ -0,0 +1,13 @@ +{ + "detailer_id": "xtrasmol_000019_1595565", + "detailer_name": "Hand Held", + "prompt": "teeny, tiny girl, minigirl, size_difference, held in hand, mini girl, on palm", + "lora": { + "lora_name": "Illustrious/Detailers/XtraSmol-000019_1595565.safetensors", + "lora_weight": 0.6, + "lora_weight_min": 0.5, + "lora_weight_max": 0.7, + "lora_triggers": "" + }, + "tags": [] +} \ No newline at end of file diff --git a/data/looks/aged_up_powerpuff_girls.json b/data/looks/aged_up_powerpuff_girls.json index 5b61fc4..1b127ea 100644 --- a/data/looks/aged_up_powerpuff_girls.json +++ b/data/looks/aged_up_powerpuff_girls.json @@ -1,14 +1,14 @@ { "look_id": "aged_up_powerpuff_girls", "look_name": "Aged Up Powerpuff Girls", - "character_id": "", + "character_id": null, "positive": "powerpuff_girls, aged_up, tight_dress", "negative": "watermark, pubic_hair, same_face, bad_anatomy", "lora": { "lora_name": "Illustrious/Looks/Aged_up_Powerpuff_Girls.safetensors", "lora_weight": 1.0, - "lora_triggers": "blossom (powerpuff girls), bubbles (powerpuff girls), buttercup (powerpuff girls)", - "lora_weight_min": 1.0, + "lora_triggers": "ppg", + "lora_weight_min": 0.4, "lora_weight_max": 1.0 }, "tags": [ diff --git a/data/looks/beardy_man_ilxl_000003.json b/data/looks/beardy_man_ilxl_000003.json index f1e32cc..1f8435c 100644 --- a/data/looks/beardy_man_ilxl_000003.json +++ b/data/looks/beardy_man_ilxl_000003.json @@ -1,16 +1,16 @@ { + "character_id": null, "look_id": "beardy_man_ilxl_000003", "look_name": "Beardy Man Ilxl 000003", - "character_id": "", - "positive": "1boy, mature_male, beard, facial_hair, solo", - "negative": "1girl, female", "lora": { "lora_name": "Illustrious/Looks/beardy-man-ilxl-000003.safetensors", "lora_weight": 0.8, "lora_triggers": "beard", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_min": 0.5, + "lora_weight_max": 0.7 }, + "negative": "1girl, female, asian, muscular", + "positive": "1boy, beard, facial_hair,", "tags": [ "1boy", "mature_male", @@ -18,4 +18,4 @@ "facial_hair", "solo" ] -} +} \ No newline at end of file diff --git a/data/looks/becky_illustrious.json b/data/looks/becky_illustrious.json index 2105c6e..f9b5e35 100644 --- a/data/looks/becky_illustrious.json +++ b/data/looks/becky_illustrious.json @@ -1,7 +1,7 @@ { "look_id": "becky_illustrious", "look_name": "Becky Illustrious", - "character_id": "", + "character_id": "becky_blackbell", "positive": "becky_blackbell, spy_x_family, 1girl, solo, brown_eyes, brown_hair, short_hair, twintails, flat_chest, hairclip, hair_scrunchie, white_scrunchie, eden_academy_school_uniform, neck_ribbon, red_ribbon, collared_shirt, black_dress, gold_trim, black_sleeves, long_sleeves", "negative": "long_hair, mature_female", "lora": { @@ -19,4 +19,4 @@ "brown_hair", "brown_eyes" ] -} +} \ No newline at end of file diff --git a/data/looks/bubblegum_ill.json b/data/looks/bubblegum_ill.json index c392620..9fce9ab 100644 --- a/data/looks/bubblegum_ill.json +++ b/data/looks/bubblegum_ill.json @@ -1,7 +1,7 @@ { "look_id": "bubblegum_ill", "look_name": "Bubblegum Ill", - "character_id": "", + "character_id": "princess_bubblegum", "positive": "princess_bonnibel_bubblegum, adventure_time, 1girl, pink_skin, pink_hair, long_hair, pink_dress, puffy_short_sleeves, long_skirt, crown, no_nose", "negative": "nose, realistic, photorealistic, 3d, human", "lora": { @@ -20,4 +20,4 @@ "pink_dress", "crown" ] -} +} \ No newline at end of file diff --git a/data/looks/cammywhiteillustrious.json b/data/looks/cammywhiteillustrious.json index 6bade3e..9461926 100644 --- a/data/looks/cammywhiteillustrious.json +++ b/data/looks/cammywhiteillustrious.json @@ -8,7 +8,7 @@ "lora_name": "Illustrious/Looks/CammyWhiteIllustrious.safetensors", "lora_weight": 1.0, "lora_triggers": "CAMSF", - "lora_weight_min": 1.0, + "lora_weight_min": 0.2, "lora_weight_max": 1.0 }, "tags": [ diff --git a/data/looks/candycanelatexlingerieill.json b/data/looks/candycanelatexlingerieill.json deleted file mode 100644 index dc094e1..0000000 --- a/data/looks/candycanelatexlingerieill.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "look_id": "candycanelatexlingerieill", - "look_name": "Candycanelatexlingerieill", - "character_id": "", - "positive": "latex, lingerie, red_capelet, striped_thighhighs, high_heels, garter_straps, panties, stripes, candy_cane", - "negative": "", - "lora": { - "lora_name": "Illustrious/Looks/candycanelatexlingerieILL.safetensors", - "lora_weight": 0.75, - "lora_triggers": "candy cane latex lingerie", - "lora_weight_min": 0.75, - "lora_weight_max": 0.75 - }, - "tags": [ - "clothing", - "lingeriefetish" - ] -} diff --git a/data/looks/faceless_ugly_bastardv1il_000014.json b/data/looks/faceless_ugly_bastardv1il_000014.json index 779f09d..027c412 100644 --- a/data/looks/faceless_ugly_bastardv1il_000014.json +++ b/data/looks/faceless_ugly_bastardv1il_000014.json @@ -1,24 +1,19 @@ { "look_id": "faceless_ugly_bastardv1il_000014", "look_name": "Faceless Ugly Bastardv1Il 000014", - "character_id": "", - "positive": "ugly_bastard, faceless_male, shaded_face, old_man, fat_man, obese, big_belly, smug", - "negative": "handsome, bishounen, muscular, thin, visible_face", + "character_id": null, + "positive": "1boy, (faceless_male:1.5),, fat_man, oji-san, male out of frame,", + "negative": "handsome, bishounen, muscular, thin, visible male face", "lora": { "lora_name": "Illustrious/Looks/Faceless-Ugly-BastardV1IL-000014.safetensors", "lora_weight": 0.8, - "lora_triggers": "UBV1F", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_triggers": "UBV1F, faceless_male", + "lora_weight_min": 1.0, + "lora_weight_max": 1.0 }, "tags": [ "ugly_bastard", "faceless_male", - "shaded_face", - "old_man", - "fat_man", - "obese", - "big_belly", - "smug" + "fat_man" ] -} +} \ No newline at end of file diff --git a/data/looks/hulkenbergmr_illu_bsinky_v1.json b/data/looks/hulkenbergmr_illu_bsinky_v1.json index 9df3a99..336a736 100644 --- a/data/looks/hulkenbergmr_illu_bsinky_v1.json +++ b/data/looks/hulkenbergmr_illu_bsinky_v1.json @@ -1,8 +1,8 @@ { "look_id": "hulkenbergmr_illu_bsinky_v1", "look_name": "Hulkenbergmr Illu Bsinky V1", - "character_id": "", - "positive": "metaphor:_refantazio, 1girl, red_hair, long_hair, blunt_bangs, sidelocks, aqua_eyes, v-shaped_eyebrows, pointy_ears, white_ascot, cleavage, breastplate, black_armor, black_capelet, high-waist_pants, blue_pants, long_sleeves, blue_sleeves, black_gloves, high_heel_boots, ankle_boots, brown_boots, halberd", + "character_id": null, + "positive": "metaphor:_refantazio, 1girl, red_hair, long_hair, blunt_bangs, sidelocks, aqua_eyes, v-shaped_eyebrows, pointy_ears, white_ascot, cleavage, breastplate, black_armor, black_capelet, high-waist_pants, dark navy_pants, long_sleeves, dark navy_sleeves, black_gloves, high_heel_boots, ankle_boots, brown_boots, halberd", "negative": "black_bodysuit, frills, black_jacket, cropped_jacket, choker, lowres, bad_anatomy, bad_hands", "lora": { "lora_name": "Illustrious/Looks/HulkenbergMR-illu-bsinky-v1.safetensors", @@ -36,4 +36,4 @@ "brown_boots", "halberd" ] -} +} \ No newline at end of file diff --git a/data/looks/starfire_il.json b/data/looks/starfire_il.json index c39324f..2a7550a 100644 --- a/data/looks/starfire_il.json +++ b/data/looks/starfire_il.json @@ -1,9 +1,7 @@ { + "character_id": null, "look_id": "starfire_il", "look_name": "Starfire Il", - "character_id": "starfire", - "positive": "1girl, starfire, green_eyes, red_hair, long_hair, small_breasts, gorget, crop_top, armlet, pencil_skirt, purple_skirt, grey_belt, thigh_boots, vambraces, purple_boots, looking_at_viewer, smile", - "negative": "lowres, bad_anatomy, bad_hands, text, error, missing_fingers, extra_digit, fewer_digits, cropped, worst_quality, low_quality, normal_quality, jpeg_artifacts, signature, watermark, username, blurry, short_hair, blue_eyes, huge_breasts", "lora": { "lora_name": "Illustrious/Looks/Starfire IL.safetensors", "lora_weight": 0.8, @@ -11,10 +9,12 @@ "lora_weight_min": 0.8, "lora_weight_max": 0.8 }, + "negative": "lowres, bad_anatomy, bad_hands, text, error, missing_fingers, extra_digit, fewer_digits, cropped, worst_quality, low_quality, normal_quality, jpeg_artifacts, signature, watermark, username, blurry, short_hair, blue_eyes, huge_breasts", + "positive": "1girl, starfire, green_eyes, red_hair, long_hair, small_breasts, gorget, crop_top, armlet, pencil_skirt, purple_skirt, grey_belt, thigh_boots, vambraces, purple_boots, looking_at_viewer, smile", "tags": [ "anime", "cartoon", "teen_titans", "woman" ] -} +} \ No newline at end of file diff --git a/data/looks/tblossom_illustriousxl.json b/data/looks/tblossom_illustriousxl.json index 604cfce..518740f 100644 --- a/data/looks/tblossom_illustriousxl.json +++ b/data/looks/tblossom_illustriousxl.json @@ -1,7 +1,7 @@ { "look_id": "tblossom_illustriousxl", "look_name": "Tblossom Illustriousxl", - "character_id": "", + "character_id": "blossom_ppg", "positive": "blossom_(ppg), 1girl, orange_hair, very_long_hair, high_ponytail, hair_bow, red_bow, huge_bow, pink_eyes, red_lips, pink_top, crop_top, midriff, navel, red_pants, belt, ribbon", "negative": "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, short_hair", "lora": { @@ -21,4 +21,4 @@ "crop_top", "red_pants" ] -} +} \ No newline at end of file diff --git a/data/looks/tifalockhartff7advchilcasual_illu_dwnsty_000006.json b/data/looks/tifalockhartff7advchilcasual_illu_dwnsty_000006.json deleted file mode 100644 index b58837c..0000000 --- a/data/looks/tifalockhartff7advchilcasual_illu_dwnsty_000006.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "look_id": "tifalockhartff7advchilcasual_illu_dwnsty_000006", - "look_name": "Tifalockhartff7Advchilcasual Illu Dwnsty 000006", - "character_id": "tifa_lockhart", - "positive": "tifa_lockhart, final_fantasy_vii:_advent_children, 1girl, solo, long_hair, black_hair, red_eyes, white_tank_top, black_vest, black_shorts, fingerless_gloves, arm_ribbon, pink_ribbon, midriff, navel, black_boots, realistic", - "negative": "lowres, worst_quality, low_quality, bad_anatomy, multiple_views, jpeg_artifacts, artist_name, young, 3d, render, doll", - "lora": { - "lora_name": "Illustrious/Looks/TifaLockhartFF7AdvChilCasual_Illu_Dwnsty-000006.safetensors", - "lora_weight": 0.8, - "lora_triggers": "tifa_lockhart, final_fantasy_vii:_advent_children", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 - }, - "tags": [ - "tifa_lockhart", - "final_fantasy_vii:_advent_children", - "white_tank_top", - "black_vest", - "black_shorts", - "fingerless_gloves", - "arm_ribbon", - "pink_ribbon", - "midriff", - "navel", - "black_boots" - ] -} diff --git a/data/looks/tifalockhartff7advchilfeather_illu_dwnsty_000006.json b/data/looks/tifalockhartff7advchilfeather_illu_dwnsty_000006.json deleted file mode 100644 index 568c9f1..0000000 --- a/data/looks/tifalockhartff7advchilfeather_illu_dwnsty_000006.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "look_id": "tifalockhartff7advchilfeather_illu_dwnsty_000006", - "look_name": "Tifalockhartff7Advchilfeather Illu Dwnsty 000006", - "character_id": "tifa_lockhart", - "positive": "tifa_lockhart, tifa_lockhart_(feather_style), black_dress, feathers, black_feathers, detached_sleeves, black_gloves, thighhighs, long_hair, red_eyes, masterpiece, best quality, high resolution, detailed eyes, realistic body, game cg", - "negative": "(lowres:1.2), (worst quality:1.4), (low quality:1.4), (bad anatomy:1.4), multiple views, jpeg artifacts, artist name, young, brown_eyes", - "lora": { - "lora_name": "Illustrious/Looks/TifaLockhartFF7AdvChilFeather_Illu_Dwnsty-000006.safetensors", - "lora_weight": 0.8, - "lora_triggers": "feathertflckhrt, tifa_lockhart, feather_style", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 - }, - "tags": [ - "tifa_lockhart", - "tifa_lockhart_(feather_style)", - "black_dress", - "feathers", - "black_feathers", - "detached_sleeves", - "black_gloves", - "thighhighs", - "long_hair", - "red_eyes" - ] -} diff --git a/data/looks/tifalockhartff7amarantsguise_illu_dwnsty_000008.json b/data/looks/tifalockhartff7amarantsguise_illu_dwnsty_000008.json deleted file mode 100644 index a9beca8..0000000 --- a/data/looks/tifalockhartff7amarantsguise_illu_dwnsty_000008.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "look_id": "tifalockhartff7amarantsguise_illu_dwnsty_000008", - "look_name": "Tifalockhartff7Amarantsguise Illu Dwnsty 000008", - "character_id": "tifa_lockhart", - "positive": "tifa_lockhart, final_fantasy_vii:_ever_crisis, red_vest, white_pants, midriff, navel, red_gloves, fingerless_gloves, sleeveless, red_eyes, black_hair, long_hair, jewelry, bangles", - "negative": "lowres, bad anatomy, bad hands, white_tank_top, skirt, suspenders, short_hair", - "lora": { - "lora_name": "Illustrious/Looks/TifaLockhartFF7AmarantsGuise_Illu_Dwnsty-000008.safetensors", - "lora_weight": 0.8, - "lora_triggers": "amarants guise, amarantstflckhrt", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 - }, - "tags": [ - "tifa_lockhart", - "final_fantasy_vii:_ever_crisis", - "red_vest", - "white_pants", - "midriff", - "navel", - "red_gloves", - "fingerless_gloves", - "sleeveless", - "red_eyes", - "black_hair", - "long_hair", - "jewelry", - "bangles" - ] -} diff --git a/data/looks/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json b/data/looks/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json deleted file mode 100644 index 88f6683..0000000 --- a/data/looks/tifalockhartff7bahamutsuit_illu_dwnsty_000006.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "look_id": "tifalockhartff7bahamutsuit_illu_dwnsty_000006", - "look_name": "Tifalockhartff7Bahamutsuit Illu Dwnsty 000006", - "character_id": "tifa_lockhart", - "positive": "tifa_lockhart_(bahamut_suit), tifa_lockhart, black_bodysuit, black_armor, mechanical_wings, dragon_wings, gauntlets, navel_cutout, thighhighs, red_eyes, black_hair, long_hair", - "negative": "short_hair, blonde_hair, blue_eyes", - "lora": { - "lora_name": "Illustrious/Looks/TifaLockhartFF7BahamutSuit_Illu_Dwnsty-000006.safetensors", - "lora_weight": 0.8, - "lora_triggers": "bahamut suit", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 - }, - "tags": [ - "tifa_lockhart_(bahamut_suit)", - "tifa_lockhart", - "black_bodysuit", - "black_armor", - "mechanical_wings", - "dragon_wings", - "gauntlets", - "navel_cutout", - "thighhighs", - "red_eyes", - "black_hair", - "long_hair" - ] -} diff --git a/data/looks/tifalockhartff7bunnybustier_illu_dwnsty.json b/data/looks/tifalockhartff7bunnybustier_illu_dwnsty.json deleted file mode 100644 index eed68bd..0000000 --- a/data/looks/tifalockhartff7bunnybustier_illu_dwnsty.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "look_id": "tifalockhartff7bunnybustier_illu_dwnsty", - "look_name": "Tifalockhartff7Bunnybustier Illu Dwnsty", - "character_id": "tifa_lockhart", - "positive": "tifa_lockhart, tifa_lockhart_(bunny_bustier), playboy_bunny, rabbit_ears, black_bustier, fishnet_pantyhose, wrist_cuffs, bowtie, black_gloves, fingerless_gloves, black_hair, long_hair, red_eyes, split_bangs, cleavage, large_breasts, detailed_eyes, realistic_body, game_cg, game_screenshot, masterpiece, best_quality, high_resolution, very_aesthetic, professional, high_quality, portrait", - "negative": "lowres, worst_quality, low_quality, bad_anatomy, multiple_views, jpeg_artifacts, artist_name, young, blurry, bad_hands, text, watermark, signature", - "lora": { - "lora_name": "Illustrious/Looks/TifaLockhartFF7BunnyBustier_Illu_Dwnsty.safetensors", - "lora_weight": 0.8, - "lora_triggers": "tifa_lockhart, tifa_lockhart_(bunny_bustier), playboy_bunny", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 - }, - "tags": [ - "tifa_lockhart", - "bunny_girl", - "playboy_bunny", - "outfit", - "black_bustier", - "fishnet_pantyhose", - "rabbit_ears" - ] -} diff --git a/data/looks/xtrasmol_000019_1595565.json b/data/looks/xtrasmol_000019_1595565.json deleted file mode 100644 index e3674c9..0000000 --- a/data/looks/xtrasmol_000019_1595565.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "look_id": "xtrasmol_000019_1595565", - "look_name": "Xtrasmol 000019 1595565", - "character_id": "", - "positive": "1girl, minigirl, size_difference, mature_female, solo", - "negative": "multiple_girls, simple_background, lowres, bad_anatomy, bad_hands, text, error, missing_fingers, extra_digit, fewer_digits, cropped, worst_quality, low_quality, normal_quality, jpeg_artifacts, signature, watermark, username, blurry", - "lora": { - "lora_name": "Illustrious/Looks/XtraSmol-000019_1595565.safetensors", - "lora_weight": 0.6, - "lora_triggers": "teeny, tinygirl", - "lora_weight_min": 0.6, - "lora_weight_max": 0.6 - }, - "tags": [ - "minigirl", - "size_difference", - "mature_female" - ] -} diff --git a/data/presets/example_01.json b/data/presets/example_01.json index 46ab3e7..7ebcbf3 100644 --- a/data/presets/example_01.json +++ b/data/presets/example_01.json @@ -2,7 +2,7 @@ "preset_id": "example_01", "preset_name": "Example Preset", "character": { - "character_id": "aerith_gainsborough", + "character_id": "anya_(spy_x_family)", "use_lora": true, "fields": { "identity": { @@ -39,11 +39,11 @@ } }, "outfit": { - "outfit_id": null, + "outfit_id": "random", "use_lora": true }, "action": { - "action_id": "random", + "action_id": null, "use_lora": true, "fields": { "full_body": true, @@ -59,7 +59,7 @@ "use_lora": true }, "scene": { - "scene_id": "random", + "scene_id": null, "use_lora": true, "fields": { "background": true, @@ -71,7 +71,7 @@ } }, "detailer": { - "detailer_id": null, + "detailer_id": "sts_age_slider_illustrious_v1", "use_lora": true }, "look": { diff --git a/data/prompts/transfer_system.txt b/data/prompts/transfer_system.txt new file mode 100644 index 0000000..13c81dd --- /dev/null +++ b/data/prompts/transfer_system.txt @@ -0,0 +1,33 @@ +You are an AI assistant that converts character profiles to other entity types. + +Your task is to transform a character profile into a different type of entity while preserving the core identity and adapting it to the new context. + +Available target types: +1. **Look** - Character appearance, facial features, body type, hair, eyes +2. **Outfit** - Clothing, accessories, fashion style +3. **Action** - Pose, activity, movement, interaction +4. **Style** - Artistic style, rendering technique, visual aesthetic +5. **Scene** - Background, environment, setting, lighting +6. **Detailer** - Enhancement, detail level, quality improvement + +Conversion Guidelines: +- Extract relevant information from the character profile +- Adapt the structure to match the target type's expected JSON format +- Preserve key visual elements that make sense for the target type +- Add appropriate fields specific to the target type +- Maintain consistency with the original character's identity + +Output Requirements: +- Return ONLY valid JSON +- No markdown formatting +- Include all required fields for the target type +- Use appropriate field names for the target type +- Keep the JSON structure clean and well-organized + +Example conversions: +- Character with "cyberpunk ninja" description → Look: focus on cybernetic features, ninja attire +- Character with "elegant ball gown" → Outfit: focus on dress details, accessories +- Character with "fighting stance" → Action: focus on pose, movement, combat details +- Character with "watercolor painting" → Style: focus on artistic technique, brush strokes +- Character in "forest clearing" → Scene: focus on environment, lighting, vegetation +- Character with "high detail" → Detailer: focus on enhancement parameters, quality settings \ No newline at end of file diff --git a/data/scenes/barbie_b3dr00m_i.json b/data/scenes/barbie_b3dr00m_i.json index adf698c..61b39c1 100644 --- a/data/scenes/barbie_b3dr00m_i.json +++ b/data/scenes/barbie_b3dr00m_i.json @@ -24,8 +24,8 @@ "lora_name": "Illustrious/Backgrounds/Barbie_b3dr00m-i.safetensors", "lora_weight": 1.0, "lora_triggers": "barbie bedroom", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.4, + "lora_weight_max": 0.8 }, "tags": [ "barbie_(franchise)", diff --git a/data/scenes/before_the_chalkboard_il.json b/data/scenes/before_the_chalkboard_il.json index 4bced32..1db1abf 100644 --- a/data/scenes/before_the_chalkboard_il.json +++ b/data/scenes/before_the_chalkboard_il.json @@ -24,7 +24,7 @@ "lora_name": "Illustrious/Backgrounds/Before_the_Chalkboard_IL.safetensors", "lora_weight": 1.0, "lora_triggers": "standing beside desk, chalkboard, indoors", - "lora_weight_min": 1.0, + "lora_weight_min": 0.6, "lora_weight_max": 1.0 }, "tags": [ diff --git a/data/scenes/laboratory___il.json b/data/scenes/laboratory___il.json index f8c3aa5..c84361b 100644 --- a/data/scenes/laboratory___il.json +++ b/data/scenes/laboratory___il.json @@ -24,8 +24,8 @@ "lora_name": "Illustrious/Backgrounds/Laboratory_-_IL.safetensors", "lora_weight": 0.7, "lora_triggers": "l4b", - "lora_weight_min": 0.7, - "lora_weight_max": 0.7 + "lora_weight_min": 0.6, + "lora_weight_max": 1.0 }, "tags": [ "indoors", diff --git a/data/scenes/mysterious_4lch3my_sh0p_i.json b/data/scenes/mysterious_4lch3my_sh0p_i.json index a3bcec0..411b146 100644 --- a/data/scenes/mysterious_4lch3my_sh0p_i.json +++ b/data/scenes/mysterious_4lch3my_sh0p_i.json @@ -1,6 +1,6 @@ { "scene_id": "mysterious_4lch3my_sh0p_i", - "scene_name": "Mysterious 4Lch3My Sh0P I", + "scene_name": "Alchemy Shop", "description": "A dimly lit, mysterious alchemy shop filled with ancient books, glowing potions, and strange magical artifacts like preserved eyes and enchanted daggers.", "scene": { "background": "indoors, alchemy laboratory, cluttered, bookshelf, potion, glass bottle, spider web, stone wall, mist, shelves, ancient", @@ -24,7 +24,7 @@ "lora_name": "Illustrious/Backgrounds/mysterious_4lch3my_sh0p-i.safetensors", "lora_weight": 1.0, "lora_triggers": "mysterious 4lch3my sh0p", - "lora_weight_min": 1.0, + "lora_weight_min": 0.6, "lora_weight_max": 1.0 }, "tags": [ @@ -34,4 +34,4 @@ "background", "magic shop" ] -} +} \ No newline at end of file diff --git a/data/scenes/privacy_screen_anyillustriousxlfor_v11_came_1420_v1_0.json b/data/scenes/privacy_screen_anyillustriousxlfor_v11_came_1420_v1_0.json index 6201189..48f594d 100644 --- a/data/scenes/privacy_screen_anyillustriousxlfor_v11_came_1420_v1_0.json +++ b/data/scenes/privacy_screen_anyillustriousxlfor_v11_came_1420_v1_0.json @@ -22,8 +22,8 @@ "lora_name": "Illustrious/Backgrounds/privacy_screen_anyillustriousXLFor_v11_came_1420_v1.0.safetensors", "lora_weight": 1.0, "lora_triggers": "privacy_curtains, hospital_curtains", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.2, + "lora_weight_max": 0.8 }, "tags": [ "hospital", diff --git a/data/styles/3dvisualart1llust.json b/data/styles/3dvisualart1llust.json index da5a1c5..f7d6428 100644 --- a/data/styles/3dvisualart1llust.json +++ b/data/styles/3dvisualart1llust.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/3DVisualArt1llust.safetensors", "lora_weight": 0.8, "lora_triggers": "3DVisualArt1llust", - "lora_weight_min": 0.8, - "lora_weight_max": 0.8 + "lora_weight_min": 0.2, + "lora_weight_max": 0.3 } } diff --git a/data/styles/748cmxl_il_lokr_v6311p_1321893.json b/data/styles/748cmxl_il_lokr_v6311p_1321893.json index c8f3f11..6295791 100644 --- a/data/styles/748cmxl_il_lokr_v6311p_1321893.json +++ b/data/styles/748cmxl_il_lokr_v6311p_1321893.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/748cmXL_il_lokr_V6311P_1321893.safetensors", "lora_weight": 0.8, "lora_triggers": "748cmXL_il_lokr_V6311P_1321893", - "lora_weight_min": 0.8, + "lora_weight_min": 0.7, "lora_weight_max": 0.8 } } diff --git a/data/styles/7b_style.json b/data/styles/7b_style.json index defb3b1..25b4784 100644 --- a/data/styles/7b_style.json +++ b/data/styles/7b_style.json @@ -3,13 +3,13 @@ "style_name": "7B Dream", "style": { "artist_name": "7b", - "artistic_style": "3d, blender, semi-realistic" + "artistic_style": "3d, blender_(medium) , semi-realistic" }, "lora": { "lora_name": "Illustrious/Styles/7b-style.safetensors", "lora_weight": 1.0, "lora_triggers": "7b-style", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.8, + "lora_weight_max": 1.2 } -} \ No newline at end of file +} diff --git a/data/styles/_reinaldo_quintero__reiq___artist_style_illustrious.json b/data/styles/_reinaldo_quintero__reiq___artist_style_illustrious.json index 772b5dc..dcfca5c 100644 --- a/data/styles/_reinaldo_quintero__reiq___artist_style_illustrious.json +++ b/data/styles/_reinaldo_quintero__reiq___artist_style_illustrious.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/[Reinaldo Quintero (REIQ)] Artist Style Illustrious.safetensors", "lora_weight": 1.0, "lora_triggers": "[Reinaldo Quintero (REIQ)] Artist Style Illustrious", - "lora_weight_min": 1.0, + "lora_weight_min": 0.2, "lora_weight_max": 1.0 } } diff --git a/data/styles/anime_artistic_2.json b/data/styles/anime_artistic_2.json index a0ded45..b1cca6d 100644 --- a/data/styles/anime_artistic_2.json +++ b/data/styles/anime_artistic_2.json @@ -3,7 +3,7 @@ "style_name": "Anime Artistic 2", "style": { "artist_name": "", - "artistic_style": "" + "artistic_style": "anime, artistic, digital painting" }, "lora": { "lora_name": "Illustrious/Styles/Anime_artistic_2.safetensors", @@ -12,4 +12,4 @@ "lora_weight_min": 1.0, "lora_weight_max": 1.0 } -} +} \ No newline at end of file diff --git a/data/styles/blossombreeze1llust.json b/data/styles/blossombreeze1llust.json index dddfe53..ab6a496 100644 --- a/data/styles/blossombreeze1llust.json +++ b/data/styles/blossombreeze1llust.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/BlossomBreeze1llust.safetensors", "lora_weight": 1.0, "lora_triggers": "BlossomBreeze1llust", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.4, + "lora_weight_max": 0.6 } } diff --git a/data/styles/brushwork1llust.json b/data/styles/brushwork1llust.json index 3f18b22..c236832 100644 --- a/data/styles/brushwork1llust.json +++ b/data/styles/brushwork1llust.json @@ -1,15 +1,15 @@ { - "style_id": "brushwork1llust", - "style_name": "Brushwork", - "style": { - "artist_name": "", - "artistic_style": "" - }, "lora": { "lora_name": "Illustrious/Styles/Brushwork1llust.safetensors", + "lora_triggers": "hshiArt", "lora_weight": 0.95, - "lora_triggers": "Brushwork1llust", - "lora_weight_min": 0.95, - "lora_weight_max": 0.95 - } + "lora_weight_max": 0.9, + "lora_weight_min": 0.7 + }, + "style": { + "artist_name": "", + "artistic_style": "brushwork,layeredtextures,loose and expressive brushstrokes,bold and rough brushstrokes" + }, + "style_id": "brushwork1llust", + "style_name": "Brushwork" } diff --git a/data/styles/cum_on_ero_figur.json b/data/styles/cum_on_ero_figur.json index 53e9b55..5a08416 100644 --- a/data/styles/cum_on_ero_figur.json +++ b/data/styles/cum_on_ero_figur.json @@ -1,15 +1,15 @@ { "style_id": "cum_on_ero_figur", - "style_name": "Cum On Ero Figur", + "style_name": "Cum On Ero Figure", "style": { "artist_name": "", - "artistic_style": "cum_on_ero_figur, figurine, (cum:1.2)" + "artistic_style": "figurine, (cum:1.2)" }, "lora": { "lora_name": "Illustrious/Styles/cum_on_ero_figur.safetensors", "lora_weight": 0.9, "lora_triggers": "cum on figure, figurine", - "lora_weight_min": 0.9, - "lora_weight_max": 0.9 + "lora_weight_min": 0.8, + "lora_weight_max": 0.95 } } diff --git a/data/styles/cunny_000024.json b/data/styles/cunny_000024.json index 2910a54..bf019b8 100644 --- a/data/styles/cunny_000024.json +++ b/data/styles/cunny_000024.json @@ -1,15 +1,15 @@ { - "style_id": "cunny_000024", - "style_name": "Cunny 000024", - "style": { - "artist_name": "", - "artistic_style": "" - }, "lora": { "lora_name": "Illustrious/Styles/cunny-000024.safetensors", - "lora_weight": 0.9, "lora_triggers": "cunny-000024", - "lora_weight_min": 0.9, - "lora_weight_max": 0.9 - } + "lora_weight": 0.9, + "lora_weight_max": 0.6, + "lora_weight_min": 0.4 + }, + "style": { + "artist_name": "", + "artistic_style": "loli" + }, + "style_id": "cunny_000024", + "style_name": "Cunny 000024" } diff --git a/data/styles/cutesexyrobutts_style_illustrious_goofy.json b/data/styles/cutesexyrobutts_style_illustrious_goofy.json index 17da495..f8ddd1e 100644 --- a/data/styles/cutesexyrobutts_style_illustrious_goofy.json +++ b/data/styles/cutesexyrobutts_style_illustrious_goofy.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/cutesexyrobutts_style_illustrious_goofy.safetensors", "lora_weight": 1.0, "lora_triggers": "cutesexyrobutts_style_illustrious_goofy", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.4, + "lora_weight_max": 0.8 } } diff --git a/data/styles/darkaesthetic2llust.json b/data/styles/darkaesthetic2llust.json index c2c8c83..8db4b47 100644 --- a/data/styles/darkaesthetic2llust.json +++ b/data/styles/darkaesthetic2llust.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/DarkAesthetic2llust.safetensors", "lora_weight": 0.9, "lora_triggers": "DarkAesthetic2llust", - "lora_weight_min": 0.9, - "lora_weight_max": 0.9 + "lora_weight_min": 0.4, + "lora_weight_max": 0.6 } } diff --git a/data/styles/etherealmist1llust.json b/data/styles/etherealmist1llust.json index 23c881e..092e6a3 100644 --- a/data/styles/etherealmist1llust.json +++ b/data/styles/etherealmist1llust.json @@ -6,10 +6,10 @@ "artistic_style": "ethereal" }, "lora": { - "lora_name": "Illustrious/Styles/EtherealMist1llust.safetensors", + "lora_name": "", "lora_weight": 1.0, "lora_triggers": "EtherealMist1llust", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.6, + "lora_weight_max": 0.8 } -} +} \ No newline at end of file diff --git a/data/styles/futurism1llust_1549997.json b/data/styles/futurism1llust_1549997.json index f05a5b2..1962b58 100644 --- a/data/styles/futurism1llust_1549997.json +++ b/data/styles/futurism1llust_1549997.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/Futurism1llust_1549997.safetensors", "lora_weight": 1.0, "lora_triggers": "Futurism1llust_1549997", - "lora_weight_min": 1.0, - "lora_weight_max": 1.0 + "lora_weight_min": 0.2, + "lora_weight_max": 0.6 } } diff --git a/data/styles/inksplash1llust_1448502.json b/data/styles/inksplash1llust_1448502.json index be3b80b..2568709 100644 --- a/data/styles/inksplash1llust_1448502.json +++ b/data/styles/inksplash1llust_1448502.json @@ -9,7 +9,7 @@ "lora_name": "Illustrious/Styles/InkSplash1llust_1448502.safetensors", "lora_weight": 0.95, "lora_triggers": "InkSplash1llust_1448502", - "lora_weight_min": 0.95, + "lora_weight_min": 0.8, "lora_weight_max": 0.95 } } diff --git a/data/styles/zzz_stroll_sticker__style__ilxl.json b/data/styles/zzz_stroll_sticker__style__ilxl.json index 948ca28..ab2287e 100644 --- a/data/styles/zzz_stroll_sticker__style__ilxl.json +++ b/data/styles/zzz_stroll_sticker__style__ilxl.json @@ -3,13 +3,13 @@ "style_name": "Zzz Stroll Sticker Style Ilxl", "style": { "artist_name": "", - "artistic_style": "" + "artistic_style": "flat, sticker, outline" }, "lora": { "lora_name": "Illustrious/Styles/ZZZ_Stroll_Sticker_(Style)_ILXL.safetensors", "lora_weight": 1.0, - "lora_triggers": "ZZZ_Stroll_Sticker_(Style)_ILXL", - "lora_weight_min": 1.0, + "lora_triggers": "", + "lora_weight_min": 0.6, "lora_weight_max": 1.0 } } diff --git a/docker-compose.yml b/docker-compose.yml index 81e7a5d..0671b1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,15 @@ services: stdin_open: true restart: unless-stopped + character-mcp: + build: https://git.liveaodh.com/aodhan/character-mcp.git + image: character-mcp:latest + stdin_open: true + restart: unless-stopped + volumes: + # Persistent cache for character data + - character-cache:/root/.local/share/character-mcp + app: build: . ports: @@ -22,8 +31,8 @@ services: - ./static/uploads:/app/static/uploads - ./instance:/app/instance - ./flask_session:/app/flask_session - # Model files (read-only — used for checkpoint/LoRA scanning) - - /Volumes/ImageModels:/ImageModels:ro + # Model files + - /Volumes/ImageModels:/ImageModels # Docker socket so the app can run danbooru-mcp tool containers - /var/run/docker.sock:/var/run/docker.sock extra_hosts: @@ -31,4 +40,8 @@ services: - "host.docker.internal:host-gateway" depends_on: - danbooru-mcp + - character-mcp restart: unless-stopped + +volumes: + character-cache: diff --git a/fix_image_modal.py b/fix_image_modal.py new file mode 100644 index 0000000..d5d1dc6 --- /dev/null +++ b/fix_image_modal.py @@ -0,0 +1,55 @@ +import glob +import re + +files = glob.glob('templates/*/detail.html') + ['templates/detail.html'] + +for filepath in files: + with open(filepath, 'r') as f: + content = f.read() + + # We need to replace: + # data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img').src)" + # with: + # onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : ''], 0)" + + # Or in some places: + # data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.querySelector('img') ? this.querySelector('img').src : '')" + # and: + # data-bs-toggle="modal" data-bs-target="#imageModal" onclick="showImage(this.src)" + + # Also there are instances like: + # onclick="showImage(this.querySelector('img').src)" + # and + # onclick="showImage(this.src)" + + content = re.sub( + r'data-bs-toggle="modal"\s+data-bs-target="#imageModal"\s*onclick="showImage\([^"]+\)"', + r'onclick="openGallery([this.querySelector(\'img\') ? this.querySelector(\'img\').src : this.src || \'\'], 0)"', + content + ) + + # Catch any remaining data-bs-toggle + content = re.sub( + r'data-bs-toggle="modal"\s+data-bs-target="#imageModal"', + r'', + content + ) + + # Catch any remaining showImage(...) + content = re.sub( + r'onclick="showImage\([^"]+\)"', + r'onclick="openGallery([this.querySelector(\'img\') ? this.querySelector(\'img\').src : this.src || \'\'], 0)"', + content + ) + + # Remove the old function showImage(src) { ... } + content = re.sub( + r'function showImage\(src\) \{\s*document\.getElementById\(\'modalImage\'\)\.src = src;\s*\}', + r'', + content + ) + + with open(filepath, 'w') as f: + f.write(content) + + print(f"Fixed {filepath}") diff --git a/fix_quotes.py b/fix_quotes.py new file mode 100644 index 0000000..2c8457c --- /dev/null +++ b/fix_quotes.py @@ -0,0 +1,17 @@ +import glob +import re + +files = glob.glob('templates/*/detail.html') + ['templates/detail.html'] + +for filepath in files: + with open(filepath, 'r') as f: + content = f.read() + + # remove literal backslashes before single quotes + content = content.replace(r"\'", "'") + + # remove literal backslashes before double quotes if any + content = content.replace(r'\"', '"') + + with open(filepath, 'w') as f: + f.write(content) diff --git a/mcp-user-guide.md b/mcp-user-guide.md deleted file mode 100644 index 0bbc034..0000000 --- a/mcp-user-guide.md +++ /dev/null @@ -1,423 +0,0 @@ -# Danbooru MCP Tag Validator — User Guide - -This guide explains how to integrate and use the `danbooru-mcp` server with an LLM to generate valid, high-quality prompts for Illustrious / Stable Diffusion models trained on Danbooru data. - ---- - -## Table of Contents - -1. [What is this?](#what-is-this) -2. [Quick Start](#quick-start) -3. [Tool Reference](#tool-reference) - - [search_tags](#search_tags) - - [validate_tags](#validate_tags) - - [suggest_tags](#suggest_tags) -4. [Prompt Engineering Workflow](#prompt-engineering-workflow) -5. [Category Reference](#category-reference) -6. [Best Practices](#best-practices) -7. [Common Scenarios](#common-scenarios) -8. [Troubleshooting](#troubleshooting) - ---- - -## What is this? - -Illustrious (and similar Danbooru-trained Stable Diffusion models) uses **Danbooru tags** as its prompt language. -Tags like `1girl`, `blue_hair`, `looking_at_viewer` are meaningful because the model was trained on images annotated with them. - -The problem: there are hundreds of thousands of valid Danbooru tags, and misspelling or inventing tags produces no useful signal — the model generates less accurate images. - -**This MCP server** lets an LLM: -- **Search** the full tag database for tag discovery -- **Validate** a proposed prompt's tags against the real Danbooru database -- **Suggest** corrections for typos or near-miss tags - -The database contains **292,500 tags**, all with ≥10 posts on Danbooru — filtering out one-off or misspelled entries. - ---- - -## Quick Start - -### 1. Add to your MCP client (Claude Desktop example) - -**Using Docker (recommended):** -```json -{ - "mcpServers": { - "danbooru-tags": { - "command": "docker", - "args": ["run", "--rm", "-i", "danbooru-mcp:latest"] - } - } -} -``` - -**Using Python directly:** -```json -{ - "mcpServers": { - "danbooru-tags": { - "command": "/path/to/danbooru-mcp/.venv/bin/python", - "args": ["/path/to/danbooru-mcp/src/server.py"] - } - } -} -``` - -### 2. Instruct the LLM - -Add a system prompt telling the LLM to use the server: - -``` -You have access to the danbooru-tags MCP server for validating Stable Diffusion prompts. -Before generating any final prompt: -1. Use validate_tags to check all proposed tags are real Danbooru tags. -2. Use suggest_tags to fix any invalid tags. -3. Only output the validated, corrected tag list. -``` - ---- - -## Tool Reference - -### `search_tags` - -Find tags by name using full-text / prefix search. - -**Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `query` | `string` | *required* | Search string. Trailing `*` added automatically for prefix match. Supports FTS5 syntax. | -| `limit` | `integer` | `20` | Max results (1–200) | -| `category` | `string` | `null` | Optional filter: `"general"`, `"artist"`, `"copyright"`, `"character"`, `"meta"` | - -**Returns:** List of tag objects: -```json -[ - { - "name": "blue_hair", - "post_count": 1079925, - "category": "general", - "is_deprecated": false - } -] -``` - -**Examples:** - -``` -Search for hair colour tags: - search_tags("blue_hair") - → blue_hair, blue_hairband, blue_hair-chan_(ramchi), … - -Search only character tags for a Vocaloid: - search_tags("hatsune", category="character") - → hatsune_miku, hatsune_mikuo, hatsune_miku_(append), … - -Boolean search: - search_tags("hair AND blue") - → tags matching both "hair" and "blue" -``` - -**FTS5 query syntax:** - -| Syntax | Meaning | -|--------|---------| -| `blue_ha*` | prefix match (added automatically) | -| `"blue hair"` | phrase match | -| `hair AND blue` | both terms present | -| `hair NOT red` | exclusion | - ---- - -### `validate_tags` - -Check a list of tags against the full Danbooru database. Returns three groups: valid, deprecated, and invalid. - -**Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `tags` | `list[string]` | Tags to validate, e.g. `["1girl", "blue_hair", "sword"]` | - -**Returns:** -```json -{ - "valid": ["1girl", "blue_hair", "sword"], - "deprecated": [], - "invalid": ["blue_hairs", "not_a_real_tag"] -} -``` - -| Key | Meaning | -|-----|---------| -| `valid` | Exists in Danbooru and is not deprecated — safe to use | -| `deprecated` | Exists but has been deprecated (an updated canonical tag exists) | -| `invalid` | Not found — likely misspelled, hallucinated, or too niche (<10 posts) | - -**Important:** Always run `validate_tags` before finalising a prompt. Invalid tags are silently ignored by the model but waste token budget and reduce prompt clarity. - ---- - -### `suggest_tags` - -Autocomplete-style suggestions for a partial or approximate tag. Results are sorted by post count (most commonly used first). Deprecated tags are **excluded**. - -**Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `partial` | `string` | *required* | Partial tag or rough approximation | -| `limit` | `integer` | `10` | Max suggestions (1–50) | -| `category` | `string` | `null` | Optional category filter | - -**Returns:** Same format as `search_tags`, sorted by `post_count` descending. - -**Examples:** - -``` -Fix a typo: - suggest_tags("looking_at_vewer") - → ["looking_at_viewer", …] - -Find the most popular sword-related tags: - suggest_tags("sword", limit=5, category="general") - → sword (337,737), sword_behind_back (7,203), … - -Find character tags for a partial name: - suggest_tags("miku", category="character") - → hatsune_miku (129,806), yuki_miku (4,754), … -``` - ---- - -## Prompt Engineering Workflow - -This is the recommended workflow for an LLM building Illustrious prompts: - -### Step 1 — Draft - -The LLM drafts an initial list of conceptual tags based on the user's description: - -``` -User: "A girl with long silver hair wearing a kimono in a Japanese garden" - -Draft tags: - 1girl, silver_hair, long_hair, kimono, japanese_garden, cherry_blossoms, - sitting, looking_at_viewer, outdoors, traditional_clothes -``` - -### Step 2 — Validate - -``` -validate_tags([ - "1girl", "silver_hair", "long_hair", "kimono", "japanese_garden", - "cherry_blossoms", "sitting", "looking_at_viewer", "outdoors", - "traditional_clothes" -]) -``` - -Response: -```json -{ - "valid": ["1girl", "long_hair", "kimono", "cherry_blossoms", "sitting", - "looking_at_viewer", "outdoors", "traditional_clothes"], - "deprecated": [], - "invalid": ["silver_hair", "japanese_garden"] -} -``` - -### Step 3 — Fix invalid tags - -``` -suggest_tags("silver_hair", limit=3) -→ [{"name": "white_hair", "post_count": 800000}, ...] - -suggest_tags("japanese_garden", limit=3) -→ [{"name": "garden", "post_count": 45000}, - {"name": "japanese_clothes", "post_count": 12000}, ...] -``` - -### Step 4 — Finalise - -``` -Final prompt: - 1girl, white_hair, long_hair, kimono, garden, cherry_blossoms, - sitting, looking_at_viewer, outdoors, traditional_clothes -``` - -All tags are validated. Prompt is ready to send to ComfyUI. - ---- - -## Category Reference - -Danbooru organises tags into five categories. Understanding them helps scope searches: - -| Category | Value | Description | Examples | -|----------|-------|-------------|---------| -| **general** | `0` | Descriptive tags for image content | `1girl`, `blue_hair`, `sword`, `outdoors` | -| **artist** | `1` | Artist/creator names | `wlop`, `natsuki_subaru` | -| **copyright** | `3` | Source material / franchise | `fate/stay_night`, `touhou`, `genshin_impact` | -| **character** | `4` | Specific character names | `hatsune_miku`, `hakurei_reimu` | -| **meta** | `5` | Image quality / format tags | `highres`, `absurdres`, `commentary` | - -**Tips:** -- For generating images, focus on **general** tags (colours, poses, clothing, expressions) -- Add **character** and **copyright** tags when depicting a specific character -- **meta** tags like `highres` and `best_quality` can improve output quality -- Avoid **artist** tags unless intentionally mimicking a specific art style - ---- - -## Best Practices - -### ✅ Always validate before generating - -```python -# Always run this before finalising -result = validate_tags(your_proposed_tags) -# Fix everything in result["invalid"] before sending to ComfyUI -``` - -### ✅ Use suggest_tags for discoverability - -Even for tags you think you know, run `suggest_tags` to find the canonical form: -- `standing` vs `standing_on_one_leg` vs `standing_split` -- `smile` vs `small_smile` vs `evil_smile` - -The tag with the highest `post_count` is almost always the right one for your intent. - -### ✅ Prefer high-post-count tags - -Higher post count = more training data = more consistent model response. - -```python -# Get the top 5 most established hair colour tags -suggest_tags("hair_color", limit=5, category="general") -``` - -### ✅ Layer specificity - -Good prompts move from general to specific: -``` -# General → Specific -1girl, # subject count -solo, # composition -long_hair, blue_hair, # hair -white_dress, off_shoulder, # clothing -smile, looking_at_viewer, # expression/pose -outdoors, garden, daytime, # setting -masterpiece, best_quality # quality -``` - -### ❌ Avoid deprecated tags - -If `validate_tags` reports a tag as `deprecated`, use `suggest_tags` to find the current replacement: - -```python -# If "nude" is deprecated, find the current tag: -suggest_tags("nude", category="general") -``` - -### ❌ Don't invent tags - -The model doesn't understand arbitrary natural language in prompts — only tags it was trained on. `beautiful_landscape` is not a Danbooru tag; `scenery` and `landscape` are. - ---- - -## Common Scenarios - -### Scenario: Character in a specific pose - -``` -# 1. Search for pose tags -search_tags("sitting", category="general", limit=10) -→ sitting, sitting_on_ground, kneeling, seiza, wariza, … - -# 2. Validate the full tag set -validate_tags(["1girl", "hatsune_miku", "sitting", "looking_at_viewer", "smile"]) -``` - -### Scenario: Specific art style - -``` -# Find copyright tags for a franchise -search_tags("genshin", category="copyright", limit=5) -→ genshin_impact, … - -# Find character from that franchise -search_tags("hu_tao", category="character", limit=3) -→ hu_tao_(genshin_impact), … -``` - -### Scenario: Quality boosting tags - -``` -# Find commonly used meta/quality tags -search_tags("quality", category="meta", limit=5) -→ best_quality, high_quality, … - -search_tags("res", category="meta", limit=5) -→ highres, absurdres, ultra-high_res, … -``` - -### Scenario: Unknown misspelling - -``` -# You typed "haor" instead of "hair" -suggest_tags("haor", limit=5) -→ [] (no prefix match) - -# Try a broader search -search_tags("long hair") -→ long_hair, long_hair_between_eyes, wavy_hair, … -``` - ---- - -## Troubleshooting - -### "invalid" tags that should be valid - -The database contains only tags with **≥10 posts**. Tags with fewer posts are intentionally excluded as they are likely misspellings, very niche, or one-off annotations. - -If a tag you expect to be valid shows as invalid: -1. Try `suggest_tags` to find a close variant -2. Use `search_tags` to explore the tag space -3. The tag may genuinely have <10 posts — use a broader synonym instead - -### Server not responding - -Check the MCP server is running and the `db/tags.db` file exists: - -```bash -# Local -python src/server.py - -# Docker -docker run --rm -i danbooru-mcp:latest -``` - -Environment variable override: -```bash -DANBOORU_TAGS_DB=/custom/path/tags.db python src/server.py -``` - -### Database needs rebuilding / updating - -Re-run the scraper (it's resumable): - -```bash -# Refresh all tags -python scripts/scrape_tags.py --no-resume - -# Update changed tags only (re-scrapes from scratch, stops at ≥10 posts boundary) -python scripts/scrape_tags.py -``` - -Then rebuild the Docker image: -```bash -docker build -f Dockerfile.prebuilt -t danbooru-mcp:latest . -``` diff --git a/models.py b/models.py index 3377cb8..5ca7e65 100644 --- a/models.py +++ b/models.py @@ -12,9 +12,27 @@ class Character(db.Model): default_fields = db.Column(db.JSON, nullable=True) image_path = db.Column(db.String(255), nullable=True) active_outfit = db.Column(db.String(100), default='default') + + # 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 def get_active_wardrobe(self): - """Get the currently active wardrobe outfit.""" + """Get the currently active wardrobe outfit. + + Priority: + 1. If active_outfit is an outfit_id from Outfit table, fetch from Outfit.data['wardrobe'] + 2. If active_outfit is a key in character's embedded wardrobe, use that + 3. Fall back to 'default' + """ + # First check if active_outfit is an outfit_id from assigned outfits + if self.active_outfit and self.active_outfit != 'default': + # Try to get from Outfit table + outfit = Outfit.query.filter_by(outfit_id=self.active_outfit).first() + if outfit and outfit.data: + return outfit.data.get('wardrobe', {}) + + # Fall back to embedded wardrobe wardrobe = self.data.get('wardrobe', {}) # Check if wardrobe is nested (new format) or flat (legacy) if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): @@ -25,11 +43,109 @@ class Character(db.Model): return wardrobe def get_available_outfits(self): - """Get list of available outfit names.""" + """Get list of available outfit objects (including embedded and assigned). + + Returns list of dicts with keys: outfit_id, name, source ('embedded' or 'assigned') + """ + outfits = [{'outfit_id': 'default', 'name': 'Default', 'source': 'embedded'}] + + # Add embedded outfits from character data wardrobe = self.data.get('wardrobe', {}) if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): - return list(wardrobe.keys()) - return ['default'] + for outfit_name in wardrobe.keys(): + if outfit_name != 'default': + outfits.append({ + 'outfit_id': outfit_name, + 'name': outfit_name.replace('_', ' ').title(), + 'source': 'embedded' + }) + + # Add assigned outfits from Outfit table + if self.assigned_outfit_ids: + for outfit_id in self.assigned_outfit_ids: + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + if outfit: + outfits.append({ + 'outfit_id': outfit.outfit_id, + 'name': outfit.name, + 'source': 'assigned' + }) + + return outfits + + def get_outfit_wardrobe(self, outfit_id=None): + """Get wardrobe data for a specific outfit. + + Args: + outfit_id: Outfit ID to get wardrobe for. If None, uses active_outfit. + + Returns: + Dict with wardrobe fields, or empty dict if not found. + """ + if outfit_id is None: + outfit_id = self.active_outfit or 'default' + + if outfit_id == 'default': + # Return embedded default wardrobe + wardrobe = self.data.get('wardrobe', {}) + if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): + return wardrobe.get('default', {}) + return wardrobe + + # Try to find in Outfit table + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + if outfit and outfit.data: + return outfit.data.get('wardrobe', {}) + + # Try embedded outfits + wardrobe = self.data.get('wardrobe', {}) + if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): + return wardrobe.get(outfit_id, {}) + + return {} + + def assign_outfit(self, outfit_id): + """Assign an outfit to this character. + + Args: + outfit_id: The outfit_id from the Outfit table to assign. + + Returns: + True if assigned, False if already assigned or outfit not found. + """ + current_ids = self.assigned_outfit_ids or [] + + # Verify outfit exists + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + if not outfit: + return False + + if outfit_id not in current_ids: + new_ids = list(current_ids) + new_ids.append(outfit_id) + self.assigned_outfit_ids = new_ids + return True + return False + + def unassign_outfit(self, outfit_id): + """Unassign an outfit from this character. + + Args: + outfit_id: The outfit_id to unassign. + + Returns: + True if unassigned, False if not found in assigned list. + """ + current_ids = self.assigned_outfit_ids or [] + if outfit_id in current_ids: + new_ids = list(current_ids) + new_ids.remove(outfit_id) + self.assigned_outfit_ids = new_ids + # Reset active outfit if we just removed it + if self.active_outfit == outfit_id: + self.active_outfit = 'default' + return True + return False def __repr__(self): return f'' @@ -40,11 +156,30 @@ class Look(db.Model): slug = db.Column(db.String(100), unique=True, nullable=False) filename = db.Column(db.String(255), nullable=True) name = db.Column(db.String(100), nullable=False) - character_id = db.Column(db.String(100), nullable=True) # linked character + character_id = db.Column(db.String(100), nullable=True) # DEPRECATED: keeping for migration + character_ids = db.Column(db.JSON, default=list) # NEW: List of character_ids data = db.Column(db.JSON, nullable=False) default_fields = db.Column(db.JSON, nullable=True) image_path = db.Column(db.String(255), nullable=True) + def get_linked_characters(self): + """Get all characters linked to this look.""" + if not self.character_ids: + return [] + return Character.query.filter(Character.character_id.in_(self.character_ids)).all() + + def add_character(self, character_id): + """Link a character to this look.""" + if not self.character_ids: + self.character_ids = [] + if character_id not in self.character_ids: + self.character_ids.append(character_id) + + def remove_character(self, character_id): + """Unlink a character from this look.""" + if self.character_ids and character_id in self.character_ids: + self.character_ids.remove(character_id) + def __repr__(self): return f'' diff --git a/plans/APP_REFACTOR.md b/plans/APP_REFACTOR.md new file mode 100644 index 0000000..b90999c --- /dev/null +++ b/plans/APP_REFACTOR.md @@ -0,0 +1,571 @@ +# APP_REFACTOR.md — Split app.py into Modules + +## Goal + +Split the 8,478-line `app.py` into a clean module structure using Flask Blueprints for routes and plain Python modules for services/utilities. The JSON files and DB remain the source of truth — no data migration needed. + +--- + +## Target Structure + +``` +app.py # ~80 lines: Flask init, config, register blueprints, startup +models.py # Unchanged +utils.py # Pure helpers (no Flask/DB deps beyond current_app) +services/ + __init__.py + comfyui.py # ComfyUI HTTP client + workflow.py # Workflow building + checkpoint settings + prompts.py # Prompt building + dedup + llm.py # LLM integration + MCP tool calls + mcp.py # MCP/Docker server lifecycle + sync.py # All sync_*() functions + job_queue.py # Background job queue + worker thread + file_io.py # LoRA/checkpoint scanning, file uploads, image saving +routes/ + __init__.py # register_blueprints() helper + characters.py # Character CRUD + generate + outfit management + outfits.py # Outfit routes + actions.py # Action routes + styles.py # Style routes + scenes.py # Scene routes + detailers.py # Detailer routes + checkpoints.py # Checkpoint routes + looks.py # Look routes + presets.py # Preset routes + generator.py # Generator mix-and-match page + gallery.py # Gallery browsing + image deletion + settings.py # Settings page + status API + context processors + strengths.py # Strengths gallery system + transfer.py # Resource transfer system + queue_api.py # /api/queue/* endpoints +``` + +--- + +## Module Contents — Exact Function Mapping + +### `app.py` (entry point, ~80 lines) + +Keep only: +- Flask app creation, config, extensions (SQLAlchemy, Session) +- Logging setup +- `from routes import register_blueprints; register_blueprints(app)` +- `with app.app_context():` block (DB init, migrations, sync calls, worker start) + +### `utils.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `parse_orientation()` | 768 | Pure logic | +| `_resolve_lora_weight()` | 833 | Pure logic | +| `allowed_file()` | 765 | Pure logic | +| `_IDENTITY_KEYS` | 850 | Constant | +| `_WARDROBE_KEYS` | 851 | Constant | +| `ALLOWED_EXTENSIONS` | 728 | Constant | +| `_LORA_DEFAULTS` | 730 | Constant | + +### `services/job_queue.py` + +| Function/Global | Current Line | Notes | +|----------------|-------------|-------| +| `_job_queue_lock` | 75 | Global | +| `_job_queue` | 76 | Global | +| `_job_history` | 77 | Global | +| `_queue_worker_event` | 78 | Global | +| `_enqueue_job()` | 81 | Needs `comfyui.queue_prompt`, `comfyui.get_history` | +| `_queue_worker()` | 102 | The background thread loop | +| `_make_finalize()` | 227 | Needs `comfyui.get_history`, `comfyui.get_image`, DB models | +| `_prune_job_history()` | 292 | Pure logic on globals | + +### `services/comfyui.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `queue_prompt()` | 1083 | HTTP POST to ComfyUI | +| `get_history()` | 1116 | HTTP GET + polling | +| `get_image()` | 1148 | HTTP GET for image bytes | +| `_ensure_checkpoint_loaded()` | 1056 | HTTP POST to load checkpoint | + +All use `current_app.config['COMFYUI_URL']` — pass URL as param or read from config. + +### `services/workflow.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `_prepare_workflow()` | 3119 | Core workflow wiring | +| `_apply_checkpoint_settings()` | 6445 | Checkpoint-specific settings | +| `_log_workflow_prompts()` | 3030 | Logging helper | +| `_build_style_workflow()` | 5089 | Style-specific workflow builder | +| `_build_checkpoint_workflow()` | 6501 | Checkpoint-specific workflow builder | +| `_queue_scene_generation()` | 5603 | Scene generation helper | +| `_queue_detailer_generation()` | 6091 | Detailer generation helper | +| `_get_default_checkpoint()` | 3273 | Reads session + DB | + +### `services/prompts.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `build_prompt()` | 933 | Core prompt builder | +| `build_extras_prompt()` | 2104 | Generator page prompt builder | +| `_dedup_tags()` | 797 | Tag deduplication | +| `_cross_dedup_prompts()` | 808 | Cross-dedup positive/negative | +| `_build_strengths_prompts()` | 7577 | Strengths-specific prompt builder | +| `_get_character_data_without_lora()` | 7563 | Helper for strengths | +| `_resolve_character()` | 853 | Resolve slug → Character | +| `_ensure_character_fields()` | 861 | Mutate selected_fields | +| `_append_background()` | 889 | Add background tag | + +### `services/llm.py` + +| Function/Constant | Current Line | Notes | +|-------------------|-------------|-------| +| `DANBOORU_TOOLS` | 1844 | Tool definitions constant | +| `call_mcp_tool()` | 1904 | Sync MCP tool call | +| `load_prompt()` | 1911 | Load system prompt from file | +| `call_llm()` | 1918 | LLM chat completion + tool loop | + +### `services/mcp.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `_ensure_mcp_repo()` | 423 | Clone danbooru-mcp repo | +| `ensure_mcp_server_running()` | 457 | Start danbooru-mcp container | +| `_ensure_character_mcp_repo()` | 496 | Clone character-mcp repo | +| `ensure_character_mcp_server_running()` | 530 | Start character-mcp container | + +### `services/sync.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `sync_characters()` | 1158 | | +| `sync_outfits()` | 1218 | | +| `ensure_default_outfit()` | 1276 | | +| `sync_looks()` | 1360 | | +| `sync_presets()` | 1415 | | +| `sync_actions()` | 1534 | | +| `sync_styles()` | 1591 | | +| `sync_detailers()` | 1648 | | +| `sync_scenes()` | 1705 | | +| `sync_checkpoints()` | 1776 | | +| `_default_checkpoint_data()` | 1762 | | +| `_resolve_preset_entity()` | 1484 | Used by preset routes too | +| `_resolve_preset_fields()` | 1494 | Used by preset routes too | +| `_PRESET_ENTITY_MAP` | 1472 | Constant | + +### `services/file_io.py` + +| Function | Current Line | Notes | +|----------|-------------|-------| +| `get_available_loras()` | 739 | Reads Settings + filesystem | +| `get_available_checkpoints()` | 750 | Reads Settings + filesystem | +| `_count_look_assignments()` | 895 | DB query | +| `_count_outfit_lora_assignments()` | 907 | DB query | +| `_scan_gallery_images()` | 7233 | Filesystem scan | +| `_enrich_with_names()` | 7276 | DB lookups for gallery | +| `_parse_comfy_png_metadata()` | 7340 | PNG metadata parsing | + +### `routes/__init__.py` + +```python +def register_blueprints(app): + from routes.characters import bp as characters_bp + from routes.outfits import bp as outfits_bp + # ... etc for all route modules + app.register_blueprint(characters_bp) + app.register_blueprint(outfits_bp) + # ... +``` + +### `routes/characters.py` + +| Route | Current Line | +|-------|-------------| +| `GET /` (index) | 2093 | +| `POST /rescan` | 2098 | +| `GET /character/` | 2289 | +| `GET/POST /character//transfer` | 2305 | +| `GET/POST /create` | 2465 | +| `GET/POST /character//edit` | 2718 | +| `POST /character//outfit/*` (6 routes) | 2800–2988 | +| `POST /character//upload` | 2989 | +| `POST /character//replace_cover_from_preview` | 3018 | +| `POST /character//generate` | 3344 | +| `POST /character//save_defaults` | 3379 | +| `GET /get_missing_characters` | 3303 | +| `POST /clear_all_covers` | 3308 | +| `POST /generate_missing` | 3316 | + +### `routes/outfits.py` + +| Route | Current Line | +|-------|-------------| +| `GET /outfits` | 3844 | +| `POST /outfits/rescan` | 3850 | +| `POST /outfits/bulk_create` | 3856 | +| `GET /outfit/` | 3966 | +| `GET/POST /outfit//edit` | 3991 | +| `POST /outfit//upload` | 4062 | +| `POST /outfit//generate` | 4091 | +| `POST /outfit//replace_cover_from_preview` | 4187 | +| `GET/POST /outfit/create` | 4199 | +| `POST /outfit//save_defaults` | 4321 | +| `POST /outfit//clone` | 4330 | +| `POST /outfit//save_json` | 4380 | +| Helper: `_get_linked_characters_for_outfit()` | 3956 | + +### `routes/actions.py` + +| Route | Current Line | +|-------|-------------| +| `GET /actions` | 4398 | +| `POST /actions/rescan` | 4403 | +| `GET /action/` | 4409 | +| `GET/POST /action//edit` | 4430 | +| `POST /action//upload` | 4501 | +| `POST /action//generate` | 4530 | +| `POST /action//replace_cover_from_preview` | 4706 | +| `POST /action//save_defaults` | 4718 | +| `POST /actions/bulk_create` | 4727 | +| `GET/POST /action/create` | 4831 | +| `POST /action//clone` | 4905 | +| `POST /action//save_json` | 4947 | +| `GET /get_missing_actions` | 3401 | +| `POST /clear_all_action_covers` | 3406 | + +### `routes/styles.py` + +| Route | Current Line | +|-------|-------------| +| `GET /styles` | 4965 | +| `POST /styles/rescan` | 4970 | +| `GET /style/` | 4976 | +| `GET/POST /style//edit` | 4997 | +| `POST /style//upload` | 5060 | +| `POST /style//generate` | 5142 | +| `POST /style//save_defaults` | 5185 | +| `POST /style//replace_cover_from_preview` | 5194 | +| `GET /get_missing_styles` | 5206 | +| `POST /clear_all_style_covers` | 5224 | +| `POST /styles/generate_missing` | 5232 | +| `POST /styles/bulk_create` | 5262 | +| `GET/POST /style/create` | 5358 | +| `POST /style//clone` | 5412 | +| `POST /style//save_json` | 5453 | + +### `routes/scenes.py` + +| Route | Current Line | +|-------|-------------| +| `GET /scenes` | 5471 | +| `POST /scenes/rescan` | 5476 | +| `GET /scene/` | 5482 | +| `GET/POST /scene//edit` | 5503 | +| `POST /scene//upload` | 5574 | +| `POST /scene//generate` | 5680 | +| `POST /scene//save_defaults` | 5721 | +| `POST /scene//replace_cover_from_preview` | 5730 | +| `POST /scenes/bulk_create` | 5742 | +| `GET/POST /scene/create` | 5844 | +| `POST /scene//clone` | 5902 | +| `POST /scene//save_json` | 5943 | +| `GET /get_missing_scenes` | 3414 | +| `POST /clear_all_scene_covers` | 3419 | + +### `routes/detailers.py` + +| Route | Current Line | +|-------|-------------| +| `GET /detailers` | 5961 | +| `POST /detailers/rescan` | 5966 | +| `GET /detailer/` | 5972 | +| `GET/POST /detailer//edit` | 5999 | +| `POST /detailer//upload` | 6062 | +| `POST /detailer//generate` | 6154 | +| `POST /detailer//save_defaults` | 6206 | +| `POST /detailer//replace_cover_from_preview` | 6215 | +| `POST /detailer//save_json` | 6227 | +| `POST /detailers/bulk_create` | 6243 | +| `GET/POST /detailer/create` | 6340 | +| `GET /get_missing_detailers` | 5211 | +| `POST /clear_all_detailer_covers` | 5216 | + +### `routes/checkpoints.py` + +| Route | Current Line | +|-------|-------------| +| `GET /checkpoints` | 6396 | +| `POST /checkpoints/rescan` | 6401 | +| `GET /checkpoint/` | 6407 | +| `POST /checkpoint//upload` | 6425 | +| `POST /checkpoint//generate` | 6531 | +| `POST /checkpoint//replace_cover_from_preview` | 6558 | +| `POST /checkpoint//save_json` | 6570 | +| `GET /get_missing_checkpoints` | 6586 | +| `POST /clear_all_checkpoint_covers` | 6591 | +| `POST /checkpoints/bulk_create` | 6598 | + +### `routes/looks.py` + +| Route | Current Line | +|-------|-------------| +| `GET /looks` | 6702 | +| `POST /looks/rescan` | 6708 | +| `GET /look/` | 6714 | +| `GET/POST /look//edit` | 6748 | +| `POST /look//upload` | 6803 | +| `POST /look//generate` | 6820 | +| `POST /look//replace_cover_from_preview` | 6898 | +| `POST /look//save_defaults` | 6910 | +| `POST /look//generate_character` | 6919 | +| `POST /look//save_json` | 7043 | +| `GET/POST /look/create` | 7060 | +| `GET /get_missing_looks` | 7103 | +| `POST /clear_all_look_covers` | 7108 | +| `POST /looks/bulk_create` | 7116 | + +### `routes/presets.py` + +| Route | Current Line | +|-------|-------------| +| `GET /presets` | 3429 | +| `GET /preset/` | 3435 | +| `POST /preset//generate` | 3442 | +| `POST /preset//replace_cover_from_preview` | 3595 | +| `POST /preset//upload` | 3608 | +| `GET/POST /preset//edit` | 3628 | +| `POST /preset//save_json` | 3710 | +| `POST /preset//clone` | 3728 | +| `POST /presets/rescan` | 3757 | +| `GET/POST /preset/create` | 3764 | +| `GET /get_missing_presets` | 3836 | + +### `routes/generator.py` + +| Route | Current Line | +|-------|-------------| +| `GET/POST /generator` | 2168 | +| `POST /generator/preview_prompt` | 2256 | + +### `routes/gallery.py` + +| Route | Current Line | +|-------|-------------| +| `GET /gallery` | 7296 | +| `GET /gallery/prompt-data` | 7414 | +| `POST /gallery/delete` | 7434 | +| `POST /resource///delete` | 7457 | + +Constants: `GALLERY_CATEGORIES`, `_MODEL_MAP` + +### `routes/settings.py` + +| Route/Function | Current Line | +|----------------|-------------| +| `GET/POST /settings` | 2066 | +| `POST /set_default_checkpoint` | 588 | +| `GET /api/status/comfyui` | 625 | +| `GET /api/comfyui/loaded_checkpoint` | 638 | +| `GET /api/status/mcp` | 659 | +| `GET /api/status/llm` | 674 | +| `GET /api/status/character-mcp` | 712 | +| `POST /get_openrouter_models` | 2035 | +| `POST /get_local_models` | 2051 | +| Context processor: `inject_comfyui_ws_url()` | 569 | +| Context processor: `inject_default_checkpoint()` | 582 | + +### `routes/strengths.py` + +| Route | Current Line | +|-------|-------------| +| `POST /strengths///generate` | 7796 | +| `GET /strengths///list` | 7892 | +| `POST /strengths///clear` | 7917 | +| `POST /strengths///save_range` | 7939 | + +Constants: `_STRENGTHS_MODEL_MAP`, `_CATEGORY_LORA_NODES`, `_STRENGTHS_DATA_DIRS` +Helper: `_prepare_strengths_workflow()` (7690) + +### `routes/transfer.py` + +| Route | Current Line | +|-------|-------------| +| `GET/POST /resource///transfer` | 8118 | + +Constants: `_RESOURCE_TRANSFER_MAP`, `_TRANSFER_TARGET_CATEGORIES` +Helper: `_create_minimal_template()` (8045) + +### `routes/queue_api.py` + +| Route | Current Line | +|-------|-------------| +| `GET /api/queue` | 306 | +| `GET /api/queue/count` | 323 | +| `POST /api/queue//remove` | 331 | +| `POST /api/queue//pause` | 348 | +| `POST /api/queue/clear` | 365 | +| `GET /api/queue//status` | 396 | + +--- + +## Dependency Graph + +``` +app.py + ├── models.py (unchanged) + ├── utils.py (no deps except stdlib) + ├── services/ + │ ├── comfyui.py ← utils (for config) + │ ├── prompts.py ← utils, models + │ ├── workflow.py ← comfyui, prompts, utils, models + │ ├── llm.py ← mcp (for tool calls) + │ ├── mcp.py ← (stdlib only: subprocess, docker) + │ ├── sync.py ← models, utils + │ ├── job_queue.py ← comfyui, models + │ └── file_io.py ← models, utils + └── routes/ + ├── All route modules ← services/*, utils, models + └── (routes never import from other routes) +``` + +**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. + +--- + +## Migration Phases + +### Phase 1 — Extract services (no route changes) + +**Order matters** — extract leaf dependencies first: + +1. **`utils.py`** — Pure constants and helpers. Zero risk. Cut `parse_orientation`, `_resolve_lora_weight`, `allowed_file`, `ALLOWED_EXTENSIONS`, `_LORA_DEFAULTS`, `_IDENTITY_KEYS`, `_WARDROBE_KEYS` out of app.py. Add imports in app.py to keep everything working. + +2. **`services/comfyui.py`** — `queue_prompt`, `get_history`, `get_image`, `_ensure_checkpoint_loaded`. These only use `requests` + config URL. Accept `comfyui_url` as parameter or read `current_app.config`. + +3. **`services/mcp.py`** — `_ensure_mcp_repo`, `ensure_mcp_server_running`, `_ensure_character_mcp_repo`, `ensure_character_mcp_server_running`. Only uses `subprocess`/`os`/`logging`. + +4. **`services/llm.py`** — `DANBOORU_TOOLS`, `call_mcp_tool`, `load_prompt`, `call_llm`. Depends on `services/mcp` for tool calls and `models.Settings` for config. + +5. **`services/prompts.py`** — `build_prompt`, `build_extras_prompt`, `_dedup_tags`, `_cross_dedup_prompts`, `_resolve_character`, `_ensure_character_fields`, `_append_background`, `_build_strengths_prompts`, `_get_character_data_without_lora`. Depends on `utils` and `models`. + +6. **`services/workflow.py`** — `_prepare_workflow`, `_apply_checkpoint_settings`, `_log_workflow_prompts`, `_build_style_workflow`, `_build_checkpoint_workflow`, `_queue_scene_generation`, `_queue_detailer_generation`, `_get_default_checkpoint`. Depends on `comfyui`, `prompts`, `utils`, `models`. + +7. **`services/sync.py`** — All `sync_*()` + `_default_checkpoint_data`, `_resolve_preset_entity`, `_resolve_preset_fields`, `_PRESET_ENTITY_MAP`. Depends on `models`, `utils`. + +8. **`services/file_io.py`** — `get_available_loras`, `get_available_checkpoints`, `_count_look_assignments`, `_count_outfit_lora_assignments`, `_scan_gallery_images`, `_enrich_with_names`, `_parse_comfy_png_metadata`. Depends on `models`, `utils`. + +9. **`services/job_queue.py`** — Queue globals, `_enqueue_job`, `_queue_worker`, `_make_finalize`, `_prune_job_history`. Depends on `comfyui`, `models`. Extract last because the worker thread references many things. + +**After Phase 1**: `app.py` still has all routes, but imports helpers from `services/*` and `utils`. Each service module is independently testable. The app should work identically. + +### Phase 2 — Extract routes into Blueprints + +**Order**: Start with the smallest/most isolated, work toward characters (largest). + +1. **`routes/queue_api.py`** — 6 routes, only depends on `job_queue` globals. Blueprint prefix: none (keeps `/api/queue/*`). + +2. **`routes/settings.py`** — Settings page + status APIs + context processors. Register context processors on the app via `app.context_processor` in `register_blueprints()`. + +3. **`routes/gallery.py`** — Gallery + resource delete. Depends on `file_io`, `models`. + +4. **`routes/transfer.py`** — Transfer system. Self-contained with its own constants. + +5. **`routes/strengths.py`** — Strengths system. Self-contained with its own constants + workflow helper. + +6. **`routes/generator.py`** — Generator page. Depends on `prompts.build_extras_prompt`. + +7. **`routes/checkpoints.py`** — Smallest category. Good test case for the category pattern. + +8. **`routes/presets.py`** — Preset CRUD. Depends on `sync._resolve_preset_*`. + +9. **`routes/looks.py`** — Look CRUD + generate_character. + +10. **`routes/detailers.py`** — Detailer CRUD. + +11. **`routes/scenes.py`** — Scene CRUD. + +12. **`routes/styles.py`** — Style CRUD. + +13. **`routes/actions.py`** — Action CRUD. + +14. **`routes/outfits.py`** — Outfit CRUD + linked characters helper. + +15. **`routes/characters.py`** — Character CRUD + outfit management + transfer. Largest blueprint (~1300 lines). Do last since it has the most cross-cutting concerns. + +**After Phase 2**: `app.py` is ~80 lines. All routes live in blueprints. Full functionality preserved. + +### Phase 3 — Verification & cleanup + +1. Run the app, test every page manually (index, detail, generate, edit, clone, delete for each category). +2. Test batch generation, generator page, gallery, settings, strengths, transfer. +3. Remove any dead imports from `app.py`. +4. Update `CLAUDE.md` to reflect new file structure. + +--- + +## Blueprint Pattern Template + +Each route blueprint follows this pattern: + +```python +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify +from models import db, Outfit # only what's needed +from services.workflow import _prepare_workflow +from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background +from services.job_queue import _enqueue_job, _make_finalize +from services.file_io import get_available_loras +from services.llm import call_llm, load_prompt +from utils import allowed_file + +import logging +logger = logging.getLogger('gaze') + +bp = Blueprint('outfits', __name__) + +@bp.route('/outfits') +def outfits_index(): + ... +``` + +--- + +## Risk Mitigation + +- **Circular imports**: Enforced by the dependency graph — routes → services → utils/models. If a service needs something from another service, import it at function level if needed. +- **`current_app` vs `app`**: Routes already use `request`, `session`, etc. which are context-local. Services that need app config use `current_app.config[...]` inside function bodies (not at module level). +- **Thread safety**: `job_queue.py` keeps the same threading globals. The worker thread is started in `app.py`'s startup block, same as before. +- **Session access**: Only route functions access `session`. Services that currently read session (like `_get_default_checkpoint`) will stay in `services/workflow.py` and import `session` from flask — this is fine since they're only called from within request context. +- **Testing**: After each phase-1 extraction, verify the app starts and the affected functionality works before proceeding to the next module. + +--- + +## Lines of Code Estimate (per module) + +| Module | Approx Lines | +|--------|-------------| +| `app.py` (final) | ~100 | +| `utils.py` | ~120 | +| `services/comfyui.py` | ~120 | +| `services/mcp.py` | ~150 | +| `services/llm.py` | ~200 | +| `services/prompts.py` | ~350 | +| `services/workflow.py` | ~400 | +| `services/sync.py` | ~800 | +| `services/job_queue.py` | ~250 | +| `services/file_io.py` | ~250 | +| `routes/characters.py` | ~1300 | +| `routes/outfits.py` | ~550 | +| `routes/actions.py` | ~570 | +| `routes/styles.py` | ~500 | +| `routes/scenes.py` | ~500 | +| `routes/detailers.py` | ~450 | +| `routes/checkpoints.py` | ~350 | +| `routes/looks.py` | ~550 | +| `routes/presets.py` | ~450 | +| `routes/generator.py` | ~150 | +| `routes/gallery.py` | ~300 | +| `routes/settings.py` | ~250 | +| `routes/strengths.py` | ~400 | +| `routes/transfer.py` | ~200 | +| `routes/queue_api.py` | ~120 | +| **Total** | **~8,430** | diff --git a/plans/OUTFIT_LOOKS_REFACTOR.md b/plans/OUTFIT_LOOKS_REFACTOR.md new file mode 100644 index 0000000..b0cd016 --- /dev/null +++ b/plans/OUTFIT_LOOKS_REFACTOR.md @@ -0,0 +1,1006 @@ +# Outfit & Looks Refactor Plan + +## Table of Contents +1. [Bug Fixes](#bug-fixes) +2. [Generate Character from Look](#generate-character-from-look) +3. [Expanded Features: Multi-Assignment](#expanded-features-multi-assignment) +4. [Outfit Handling Refactor](#outfit-handling-refactor) +5. [Migration Script](#migration-script) +6. [Implementation Phases](#implementation-phases) + +--- + +## Bug Fixes + +### Issue: Preview Gallery Not Showing on Looks Detail Pages + +**Root Cause:** +The `look_detail()` route in `app.py` does not scan for or pass `existing_previews` to the template, while all other resource detail routes (`outfit_detail`, `action_detail`, etc.) do. + +**Fix Required:** +Update `look_detail()` in `app.py` to scan for existing preview images: + +```python +@app.route('/look/') +def look_detail(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Pre-select the linked character if set + preferences = session.get(f'prefs_look_{slug}') + preview_image = session.get(f'preview_look_{slug}') + selected_character = session.get(f'char_look_{slug}', look.character_id or '') + + # FIX: Add existing_previews scanning (matching other resource routes) + upload_folder = current_app.config.get('UPLOAD_FOLDER', 'static/uploads') + preview_dir = os.path.join(upload_folder, 'looks', slug) + existing_previews = [] + if os.path.isdir(preview_dir): + for f in os.listdir(preview_dir): + if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): + existing_previews.append(f'looks/{slug}/{f}') + existing_previews.sort() + + return render_template('looks/detail.html', look=look, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, + existing_previews=existing_previews) # <-- ADD THIS +``` + +--- + +## Generate Character from Look + +### Overview +Allow users to generate a character JSON using an existing look as input, similar to the resource transfer feature but without moving or removing any files. The look's LoRA will be automatically assigned to the new character. + +### Backend Implementation + +**New Route:** `POST /look//generate_character` + +```python +@app.route('/look//generate_character', methods=['POST']) +def generate_character_from_look(slug): + """Generate a character JSON using a look as the base.""" + look = Look.query.filter_by(slug=slug).first_or_404() + + # Get or validate inputs + character_name = request.form.get('character_name', look.look_name) + use_llm = request.form.get('use_llm') == 'on' + + # Auto-generate slug + character_slug = re.sub(r'[^a-zA-Z0-9]+', '_', character_name.lower()).strip('_') + character_slug = re.sub(r'[^a-zA-Z0-9_]', '', character_slug) + + # Find available filename + base_slug = character_slug + counter = 1 + while os.path.exists(os.path.join(CHARACTERS_DIR, f"{character_slug}.json")): + character_slug = f"{base_slug}_{counter}" + counter += 1 + + if use_llm: + # Use LLM to generate character from look context + prompt = f"""Generate a character based on this look description: + +Look Name: {look.look_name} +Positive Prompt: {look.data.get('positive', '')} +Negative Prompt: {look.data.get('negative', '')} +Tags: {', '.join(look.data.get('tags', []))} +LoRA Triggers: {look.data.get('lora', {}).get('lora_triggers', '')} + +Create a complete character JSON with identity, styles, and appropriate wardrobe fields. +The character should match the visual style described in the look.""" + + # Call LLM generation (reuse existing LLM infrastructure) + character_data = generate_character_with_llm(prompt, character_name, character_slug) + else: + # Create minimal character template + character_data = { + "character_id": character_slug, + "character_name": character_name, + "identity": { + "base_specs": look.data.get('lora', {}).get('lora_triggers', ''), + "hair": "", + "eyes": "", + "hands": "", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "" + }, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + }, + "styles": { + "aesthetic": "", + "primary_color": "", + "secondary_color": "", + "tertiary_color": "" + }, + "lora": look.data.get('lora', {}), # <-- Auto-assign look's LoRA + "tags": look.data.get('tags', []) + } + + # Save character JSON + char_path = os.path.join(CHARACTERS_DIR, f"{character_slug}.json") + with open(char_path, 'w') as f: + json.dump(character_data, f, indent=2) + + # Create DB entry + character = Character( + character_id=character_slug, + slug=character_slug, + name=character_name, + data=character_data + ) + db.session.add(character) + db.session.commit() + + # Link the look to this character + look.character_id = character_slug + db.session.commit() + + flash(f'Character "{character_name}" created from look!', 'success') + return redirect(url_for('detail', slug=character_slug)) +``` + +### Frontend Implementation + +**Update `templates/looks/detail.html`:** + +Add a "Generate Character" button in the header actions: + +```html + + + + + +``` + +--- + +## Expanded Features: Multi-Assignment + +### Allow a Look to be Assigned to More Than One Character + +**Current State:** +- Look has a single `character_id` field (string) +- Template shows single character selector + +**New State:** +- Look has `character_ids` field (list of strings) +- Template allows multiple character selection + +#### Database/Model Changes + +**Update `Look` model in `models.py`:** + +```python +class Look(ResourceMixin, db.Model): + __tablename__ = 'looks' + + look_id = db.Column(db.String, primary_key=True) + slug = db.Column(db.String, unique=True, nullable=False) + name = db.Column(db.String, nullable=False) + data = db.Column(db.JSON) + image_path = db.Column(db.String) + default_fields = db.Column(db.JSON) + + # OLD: Single character link + # character_id = db.Column(db.String, db.ForeignKey('characters.character_id')) + # character = db.relationship('Character', back_populates='looks') + + # NEW: Multiple character links + character_ids = db.Column(db.JSON, default=list) # List of character_ids + + def get_linked_characters(self): + """Get all characters linked to this look.""" + if not self.character_ids: + return [] + return Character.query.filter(Character.character_id.in_(self.character_ids)).all() + + def add_character(self, character_id): + """Link a character to this look.""" + if not self.character_ids: + self.character_ids = [] + if character_id not in self.character_ids: + self.character_ids.append(character_id) + + def remove_character(self, character_id): + """Unlink a character from this look.""" + if self.character_ids and character_id in self.character_ids: + self.character_ids.remove(character_id) +``` + +**Migration for existing data:** +```python +# One-time migration: convert character_id to character_ids list +for look in Look.query.all(): + if look.character_id and not look.character_ids: + look.character_ids = [look.character_id] + look.character_id = None # Clear old field +db.session.commit() +``` + +#### UI Changes for Multi-Character Assignment + +**Update `templates/looks/detail.html`:** + +Replace single character selector with multi-select: + +```html + +{# #} + + +
+
+ Linked Characters + (Check to link this look) +
+
+ {% for char in characters %} +
+ + +
+ {% endfor %} +
+
+``` + +--- + +### Allow Outfits to be Assigned to Characters as Additional Wardrobe Items + +**New Relationship:** Many-to-many between Characters and Outfits + +**Implementation Options:** + +**Option A: JSON Array in Character (Simplest)** +```python +class Character(db.Model): + # ... existing fields ... + assigned_outfit_ids = db.Column(db.JSON, default=list) # List of outfit_ids + default_outfit_id = db.Column(db.String, default='default') # Always have a default +``` + +**Option B: Association Table (More Robust)** +```python +character_outfits = db.Table('character_outfits', + db.Column('character_id', db.String, db.ForeignKey('characters.character_id')), + db.Column('outfit_id', db.String, db.ForeignKey('outfits.outfit_id')), + db.Column('is_default', db.Boolean, default=False) +) +``` + +**Recommended: Option A** for simplicity and alignment with existing JSON-heavy architecture. + +#### Character Model Updates + +```python +class Character(db.Model): + character_id = db.Column(db.String, primary_key=True) + slug = db.Column(db.String, unique=True, nullable=False) + name = db.Column(db.String, nullable=False) + data = db.Column(db.JSON) + active_outfit = db.Column(db.String, default='default') + + # NEW: List of assigned outfit IDs (empty list means only default) + assigned_outfit_ids = db.Column(db.JSON, default=list) + + def get_available_outfits(self): + """Get all outfits available to this character (assigned + default).""" + outfit_ids = ['default'] # Always include default + if self.assigned_outfit_ids: + outfit_ids.extend(self.assigned_outfit_ids) + + # Fetch outfit objects from DB + outfits = Outfit.query.filter(Outfit.outfit_id.in_(outfit_ids)).all() + + # Sort so default is first + outfits.sort(key=lambda o: (o.outfit_id != 'default', o.outfit_name)) + return outfits + + def get_active_wardrobe(self): + """Get wardrobe data from the active outfit.""" + if self.active_outfit and self.active_outfit != 'default': + outfit = Outfit.query.filter_by(outfit_id=self.active_outfit).first() + if outfit and outfit.data.get('wardrobe'): + return outfit.data['wardrobe'] + + # Fallback to legacy embedded wardrobe or empty + return self.data.get('wardrobe', {}) if self.data else {} + + def assign_outfit(self, outfit_id): + """Assign an outfit to this character.""" + if not self.assigned_outfit_ids: + self.assigned_outfit_ids = [] + if outfit_id not in self.assigned_outfit_ids: + self.assigned_outfit_ids.append(outfit_id) + + def unassign_outfit(self, outfit_id): + """Remove an outfit assignment.""" + if self.assigned_outfit_ids and outfit_id in self.assigned_outfit_ids: + self.assigned_outfit_ids.remove(outfit_id) + # Reset to default if we just removed the active outfit + if self.active_outfit == outfit_id: + self.active_outfit = 'default' +``` + +#### UI for Outfit Assignment + +**Update `templates/detail.html` (character detail):** + +Add an outfit management section: + +```html + +
+
+ Wardrobe +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + + {% if character.assigned_outfit_ids %} +
+ +
+ {% for outfit_id in character.assigned_outfit_ids %} + {% set outfit = get_outfit_by_id(outfit_id) %} + {% if outfit %} + + {{ outfit.outfit_name }} + × + + {% endif %} + {% endfor %} +
+
+ {% endif %} +
+
+``` + +--- + +## Outfit Handling Refactor + +### Overview +Character JSON files will no longer contain built-in wardrobe items. Instead, they will reference external outfit files. This requires changes to: +1. Character JSON schema +2. Character creation flow +3. Prompt building logic + +### New Character JSON Schema + +**OLD Structure:** +```json +{ + "character_id": "aerith_gainsborough", + "character_name": "Aerith Gainsborough", + "identity": { ... }, + "wardrobe": { + "full_body": "pink_dress, jacket", + "top": "...", + ... + }, + "styles": { ... }, + "lora": { ... } +} +``` + +**NEW Structure:** +```json +{ + "character_id": "aerith_gainsborough", + "character_name": "Aerith Gainsborough", + "identity": { ... }, + "styles": { ... }, + "lora": { ... }, + "tags": [...], + "defaults": { + "expression": "", + "pose": "", + "scene": "", + "outfit": "aerith_gainsborough - default" // Reference to outfit file + } +} +``` + +The `wardrobe` section is removed from the character JSON. Wardrobe data now lives exclusively in outfit files in `data/clothing/`. + +### Prompt Building Refactor + +**Current Prompt Building (in `_prepare_workflow` or similar):** +```python +# Build wardrobe from character's embedded wardrobe +wardrobe_data = character.data.get('wardrobe', {}) +wardrobe_parts = [v for k, v in wardrobe_data.items() if v and include_field(f'wardrobe::{k}')] +if wardrobe_parts: + prompt_parts.append(', '.join(wardrobe_parts)) +``` + +**New Prompt Building:** +```python +def get_outfit_wardrobe(character, outfit_id=None): + """ + Get wardrobe data from the referenced outfit file. + Falls back to default outfit if outfit_id not found. + """ + if outfit_id is None: + outfit_id = character.data.get('defaults', {}).get('outfit', 'default') + + # Try to load outfit from DB/file + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + + if outfit and outfit.data: + return outfit.data.get('wardrobe', {}) + + # Fallback to default outfit + default_outfit = Outfit.query.filter_by(outfit_id='default').first() + if default_outfit and default_outfit.data: + return default_outfit.data.get('wardrobe', {}) + + # Final fallback: empty wardrobe + return {} + +# In prompt building: +wardrobe_data = get_outfit_wardrobe(character, outfit_id) +wardrobe_parts = [v for k, v in wardrobe_data.items() if v and include_field(f'wardrobe::{k}')] +if wardrobe_parts: + prompt_parts.append(', '.join(wardrobe_parts)) +``` + +### Character Creation Refactor (Two-Step LLM Flow) + +When creating a character, we now need to either: +1. Generate an outfit first, then the character, OR +2. Assign an existing outfit + +**New Character Creation Flow:** + +```python +@app.route('/create', methods=['GET', 'POST']) +def create_character(): + if request.method == 'POST': + name = request.form.get('name') + 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') + + # ... slug generation and validation ... + + if use_llm: + # Step 1: Generate outfit first (if requested) + if outfit_mode == 'generate': + outfit_prompt = f"""Generate an outfit for character "{name}". +The character is described as: {prompt} + +Create an outfit JSON with wardrobe fields appropriate for this character.""" + + outfit_data = generate_outfit_with_llm(outfit_prompt, outfit_slug, name) + + # Save the outfit + outfit_path = os.path.join(CLOTHING_DIR, f"{outfit_slug}.json") + with open(outfit_path, 'w') as f: + json.dump(outfit_data, f, indent=2) + + outfit = Outfit( + outfit_id=outfit_slug, + slug=outfit_slug, + name=f"{name} - default", + data=outfit_data + ) + db.session.add(outfit) + db.session.commit() + + default_outfit_id = outfit_slug + + elif outfit_mode == 'existing': + default_outfit_id = existing_outfit_id + else: + default_outfit_id = 'default' + + # Step 2: Generate character (without wardrobe section) + char_prompt = f"""Generate a character named "{name}". +Description: {prompt} + +Default Outfit: {default_outfit_id} + +Create a character JSON with identity, styles, and defaults sections. +Do NOT include a wardrobe section - the outfit is handled separately.""" + + character_data = generate_character_with_llm(char_prompt, name, slug) + + # Ensure outfit reference is set + if 'defaults' not in character_data: + character_data['defaults'] = {} + character_data['defaults']['outfit'] = default_outfit_id + + # Remove any wardrobe section that LLM might have added + character_data.pop('wardrobe', None) + + else: + # Non-LLM: Create minimal character + character_data = { + "character_id": slug, + "character_name": name, + "identity": { + "base_specs": prompt, + "hair": "", + "eyes": "", + "hands": "", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "", + "outfit": existing_outfit_id if outfit_mode == 'existing' else 'default' + }, + "styles": { + "aesthetic": "", + "primary_color": "", + "secondary_color": "", + "tertiary_color": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 1, + "lora_weight_min": 0.7, + "lora_weight_max": 1, + "lora_triggers": "" + }, + "tags": [] + } + + # ... save character ... + + # If outfit was generated, assign it to the character + if outfit_mode == 'generate' and default_outfit_id != 'default': + character.assigned_outfit_ids = [default_outfit_id] + db.session.commit() +``` + +**Update `templates/create.html`:** + +```html + +
+ +
+ + + + + + + + +
+
+ + + + + +``` + +--- + +## Migration Script + +### Extract Existing Wardrobes to Outfit Files + +Before the refactor can go live, we need to extract existing wardrobe data from character JSON files into the outfits directory. + +**Script: `tools/migrate_wardrobes.py`** + +```python +#!/usr/bin/env python3 +""" +Migration script: Extract wardrobes from character JSON files into outfit files. + +Usage: + python tools/migrate_wardrobes.py [--dry-run] + +This will: +1. Read each character JSON in data/characters/ +2. Extract the wardrobe section +3. Create a new outfit file: data/clothing/{char_name} - default.json +4. Update the character JSON to remove wardrobe and add defaults.outfit reference +""" + +import os +import sys +import json +import re +from pathlib import Path + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +CHARACTERS_DIR = Path('data/characters') +CLOTHING_DIR = Path('data/clothing') + +def sanitize_filename(name): + """Convert name to safe filename.""" + # Remove/replace unsafe characters + safe = re.sub(r'[<>:"/\\|?*]', '', name) + safe = safe.strip() + return safe + +def migrate_character(char_path, dry_run=True): + """Migrate a single character file.""" + with open(char_path, 'r') as f: + data = json.load(f) + + char_id = data.get('character_id', '') + char_name = data.get('character_name', char_id) + wardrobe = data.get('wardrobe', {}) + + # Skip if no wardrobe or wardrobe is empty + if not wardrobe or not any(wardrobe.values()): + print(f" Skipping {char_name} - no wardrobe data") + return + + # Generate outfit filename + outfit_name = f"{char_name} - default" + outfit_slug = sanitize_filename(outfit_name).lower().replace(' ', '_') + outfit_filename = f"{outfit_slug}.json" + outfit_path = CLOTHING_DIR / outfit_filename + + # Handle duplicates + counter = 1 + while outfit_path.exists(): + outfit_slug = f"{sanitize_filename(char_name).lower().replace(' ', '_')}_{counter}_default" + outfit_filename = f"{outfit_slug}.json" + outfit_path = CLOTHING_DIR / outfit_filename + counter += 1 + + # Create outfit data + outfit_data = { + "outfit_id": outfit_slug, + "outfit_name": f"{char_name} - default", + "wardrobe": wardrobe, + "lora": { + "lora_name": "", + "lora_weight": 0.8, + "lora_triggers": "", + "lora_weight_min": 0.8, + "lora_weight_max": 0.8 + }, + "tags": ["default", char_name.lower().replace(' ', '_')] + } + + # Copy LoRA from character if present + char_lora = data.get('lora', {}) + if char_lora and char_lora.get('lora_name'): + outfit_data['lora'] = char_lora + + print(f" Creating outfit: {outfit_filename}") + print(f" Wardrobe fields: {list(wardrobe.keys())}") + + if not dry_run: + # Write outfit file + with open(outfit_path, 'w') as f: + json.dump(outfit_data, f, indent=2) + + # Update character JSON + # Remove wardrobe section + data.pop('wardrobe', None) + + # Ensure defaults exists and add outfit reference + if 'defaults' not in data: + data['defaults'] = {} + data['defaults']['outfit'] = outfit_slug + + # Write updated character file + with open(char_path, 'w') as f: + json.dump(data, f, indent=2) + + print(f" Updated character file") + + return outfit_slug + +def main(): + import argparse + parser = argparse.ArgumentParser(description='Migrate character wardrobes to outfit files') + parser.add_argument('--dry-run', action='store_true', + help='Show what would be done without making changes') + args = parser.parse_args() + + if args.dry_run: + print("=== DRY RUN MODE (no files will be modified) ===\n") + + # Ensure clothing directory exists + CLOTHING_DIR.mkdir(parents=True, exist_ok=True) + + # Process all character files + char_files = list(CHARACTERS_DIR.glob('*.json')) + print(f"Found {len(char_files)} character files") + print() + + migrated = 0 + skipped = 0 + + for char_path in sorted(char_files): + print(f"Processing: {char_path.name}") + result = migrate_character(char_path, dry_run=args.dry_run) + if result: + migrated += 1 + else: + skipped += 1 + + print() + print(f"Migration complete:") + print(f" Migrated: {migrated}") + print(f" Skipped (no wardrobe): {skipped}") + + if args.dry_run: + print() + print("This was a dry run. Run without --dry-run to apply changes.") + +if __name__ == '__main__': + main() +``` + +**Example Output:** +``` +=== DRY RUN MODE (no files will be modified) === + +Found 45 character files + +Processing: 2b.json + Creating outfit: 2b_-_default.json + Wardrobe fields: ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories'] +Processing: aerith_gainsborough.json + Creating outfit: aerith_gainsborough_-_default.json + Wardrobe fields: ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories'] +Processing: barret_wallace.json + Skipping Barret Wallace - no wardrobe data + +Migration complete: + Migrated: 42 + Skipped (no wardrobe): 3 +``` + +--- + +## Implementation Phases + +### Phase 1: Bug Fix & Foundation (Immediate) +- [ ] Fix `look_detail()` to pass `existing_previews` (5 min fix) +- [ ] Create migration script `tools/migrate_wardrobes.py` +- [ ] Run migration script to extract existing wardrobes + +### Phase 2: Character Generation from Look +- [ ] Add `generate_character_from_look` route to `app.py` +- [ ] Add modal UI to `templates/looks/detail.html` +- [ ] Test with both LLM and non-LLM paths + +### Phase 3: Multi-Character Look Assignment +- [ ] Add `character_ids` JSON column to `Look` model +- [ ] Create migration to convert `character_id` → `character_ids` +- [ ] Update `look_detail` route to handle multiple characters +- [ ] Update `templates/looks/detail.html` with multi-select UI +- [ ] Update look generation logic to respect all linked characters + +### Phase 4: Outfit-to-Character Assignment +- [ ] Add `assigned_outfit_ids` and `default_outfit_id` to `Character` model +- [ ] Add `get_available_outfits()` and `get_active_wardrobe()` methods +- [ ] Create routes: `assign_outfit`, `unassign_outfit`, `switch_outfit` +- [ ] Update `templates/detail.html` with outfit management UI +- [ ] Update outfit detail page to show linked characters + +### Phase 5: Outfit Handling Refactor +- [ ] Update character creation to support two-step LLM flow +- [ ] Update `templates/create.html` with outfit mode selector +- [ ] Refactor `_prepare_workflow` / prompt building to pull from outfit files +- [ ] Create default outfit file if it doesn't exist +- [ ] Update character detail page to show wardrobe from external outfit + +### Phase 6: Testing & Cleanup +- [ ] Verify all existing characters work with new outfit system +- [ ] Test character creation with all outfit modes +- [ ] Test look generation with multiple linked characters +- [ ] Clean up any legacy code referencing embedded wardrobes +- [ ] Update documentation (CLAUDE.md, DEVELOPMENT_GUIDE.md) + +--- + +## Database Migration Summary + +### Migration 1: Look Multi-Character Support +```sql +-- Add new column +ALTER TABLE looks ADD COLUMN character_ids JSON; + +-- Migrate existing data +UPDATE looks SET character_ids = JSON_ARRAY(character_id) WHERE character_id IS NOT NULL; +UPDATE looks SET character_ids = JSON_ARRAY() WHERE character_id IS NULL; + +-- Optionally drop old column after migration is verified +-- ALTER TABLE looks DROP COLUMN character_id; +``` + +### Migration 2: Character Outfit Assignment +```sql +-- Add new columns +ALTER TABLE characters ADD COLUMN assigned_outfit_ids JSON DEFAULT '[]'; +ALTER TABLE characters ADD COLUMN default_outfit_id VARCHAR(255) DEFAULT 'default'; +``` + +### Migration 3: Ensure Default Outfit Exists +```python +# Ensure there's a default outfit in the database +default_outfit = Outfit.query.filter_by(outfit_id='default').first() +if not default_outfit: + default_outfit = Outfit( + outfit_id='default', + slug='default', + name='Default Outfit', + data={'wardrobe': {}, 'lora': {}, 'tags': ['default']} + ) + db.session.add(default_outfit) + db.session.commit() +``` + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `app.py` | Add `existing_previews` to `look_detail()`, add `generate_character_from_look` route, update `create_character` for two-step flow, update prompt building | +| `models.py` | Add `character_ids` to `Look`, add `assigned_outfit_ids`/`default_outfit_id` to `Character`, add helper methods | +| `templates/looks/detail.html` | Add generate character modal, update to multi-character selector | +| `templates/detail.html` | Add outfit management section | +| `templates/create.html` | Add outfit mode selector (generate/existing/none) | +| `templates/outfits/detail.html` | Show linked characters | +| `tools/migrate_wardrobes.py` | New migration script | +| `CLAUDE.md` | Update character JSON schema documentation | +| `DEVELOPMENT_GUIDE.md` | Update development guidelines | + +--- + +## Backward Compatibility Notes + +1. **Migration must be run first** - All existing characters need wardrobes extracted before the new code can read them +2. **Default outfit** - All characters must have a default outfit reference (even if empty) +3. **Character data structure** - The JSON structure change is breaking; ensure migration script handles all edge cases +4. **Look character_id** - Keep the old field temporarily during migration, remove after `character_ids` is populated + +--- + +## Testing Checklist + +- [ ] Look preview gallery displays correctly +- [ ] Generate character from look works (LLM mode) +- [ ] Generate character from look works (non-LLM mode) +- [ ] Look's LoRA is auto-assigned to new character +- [ ] Multiple characters can be linked to a look +- [ ] Look generation respects all linked characters +- [ ] Existing outfits can be assigned to characters +- [ ] Characters can switch between assigned outfits +- [ ] Character creation with generated outfit works +- [ ] Character creation with existing outfit works +- [ ] Character creation with default outfit works +- [ ] Prompt building pulls from correct outfit file +- [ ] Fallback to default outfit if referenced outfit missing +- [ ] Migration script extracts all wardrobes correctly +- [ ] No data loss during migration diff --git a/plans/TRANSFER.md b/plans/TRANSFER.md new file mode 100644 index 0000000..1b9da9e --- /dev/null +++ b/plans/TRANSFER.md @@ -0,0 +1,149 @@ +# Resource Transfer (Non-Character) — Implementation Plan + +## Scope + +### In scope +- Add **Transfer** support and UI to these resource types: + - **Looks** + - **Outfits** + - **Actions** + - **Styles** + - **Scenes** + - **Detailers** + +### Out of scope / explicitly excluded +- **Characters** (existing flow may remain separate) +- **Checkpoints** +- **Presets** + +## User experience requirements + +### Entry points +- A **Transfer** button should appear on **all supported resource detail pages**. +- Clicking **Transfer** opens a transfer form for the currently viewed resource. + +### Transfer form fields +The form must allow the user to: +- Select **target category** (destination resource type) +- Edit **new name** +- Edit **new id/slug** +- Optionally choose **Use LLM** to regenerate JSON (recommended default: enabled) + +All of these fields should be **pre-populated using the existing resource’s information**. + +### Post-submit behavior +- After transfer completes, redirect to the **target category index page** (e.g. `/looks`). +- **Highlight** the newly created item on the index page. +- Do **not** auto-generate previews/covers. + +## Current state (reference implementation) + +- Character-only transfer route exists: [`transfer_character()`](../app.py:2216) +- Character detail page shows Transfer in header: [`templates/detail.html`](../templates/detail.html:79) +- Resource detail templates do not currently show Transfer (examples): + - Looks: [`templates/looks/detail.html`](../templates/looks/detail.html:123) + - Outfits: [`templates/outfits/detail.html`](../templates/outfits/detail.html:118) + - Actions: [`templates/actions/detail.html`](../templates/actions/detail.html:129) + +## Proposed backend design + +### 1) Add a generic resource transfer route +Implement a category-based route: + +- `GET|POST /resource///transfer` + +Responsibilities: +- Validate `category` is in allowlist: `looks,outfits,actions,styles,scenes,detailers` +- Load the appropriate SQLAlchemy model instance by `slug` +- Render a resource-agnostic transfer form on GET +- On POST: + - Validate new name/id constraints + - Generate a safe/unique slug for the destination category + - Create destination JSON (LLM path or non-LLM fallback) + - Write JSON file to correct `data//` directory + - Create DB row for the new resource + - Redirect to destination index with highlight + +### 2) Shared mapping table +Create a mapping table/dict: +- `category → model_class` +- `category → target_dir` +- `category → id_field` (e.g. `look_id`, `outfit_id`, ...) +- `category → index_route` (e.g. `looks_index`) + +This mirrors the `target_type` mapping approach in [`transfer_character()`](../app.py:2251). + +## JSON generation strategy + +### LLM path (primary) +- Use an explicit transfer prompt (existing code loads `transfer_system.txt` in [`transfer_character()`](../app.py:2289)). +- Provide the source resource JSON as input context. +- Instruct the LLM: + - to output **JSON only** (no markdown) + - to conform to the **target resource schema** +- After parsing: + - enforce required fields: `_id` and `_name` (pattern used in [`transfer_character()`](../app.py:2316)) + +### Non-LLM fallback +- Create a minimal template JSON for the target type. +- Copy safe common fields where present (e.g. `tags`, `lora`). +- Add a provenance description like “Transferred from ”. + +## Frontend changes + +### 1) Add Transfer button to supported resource detail pages +Add a Transfer button near existing header actions, pointing to `/resource///transfer`. + +Targets: +- [`templates/looks/detail.html`](../templates/looks/detail.html:123) +- [`templates/outfits/detail.html`](../templates/outfits/detail.html:118) +- [`templates/actions/detail.html`](../templates/actions/detail.html:129) +- [`templates/styles/detail.html`](../templates/styles/detail.html:1) +- [`templates/scenes/detail.html`](../templates/scenes/detail.html:1) +- [`templates/detailers/detail.html`](../templates/detailers/detail.html:1) + +### 2) Highlight the new item on the destination index +Redirect scheme: +- `/looks?highlight=` (similar for each category) + +Index templates to update: +- [`templates/looks/index.html`](../templates/looks/index.html:1) +- [`templates/outfits/index.html`](../templates/outfits/index.html:1) +- [`templates/actions/index.html`](../templates/actions/index.html:1) +- [`templates/styles/index.html`](../templates/styles/index.html:1) +- [`templates/scenes/index.html`](../templates/scenes/index.html:1) +- [`templates/detailers/index.html`](../templates/detailers/index.html:1) + +Implementation idea: +- Add `data-slug="..."` to each item card/container and a small script that: + - reads `highlight` query param + - finds the element + - scrolls into view + - applies a temporary CSS class (e.g. `highlight-pulse`) + +## Validation & error handling + +- Reject invalid/unsupported categories. +- Enforce name length limits (existing character transfer uses `<= 100` in [`transfer_character()`](../app.py:2228)). +- Sanitize id/slug to safe filename characters. +- Ensure destination JSON filename is unique; if exists, auto-suffix `_2`, `_3`, ... +- LLM error handling: + - JSON parse failures should show a user-facing flash message + - preserve form inputs on redirect if possible + +## Testing + +Add tests covering: +- Happy path transfer for one representative resource (e.g. look → outfit) +- Invalid category rejected +- Duplicate slug resolution works +- LLM returns invalid JSON → proper failure handling + +## Deliverables checklist + +- New transfer route and supporting helper mapping in app backend +- Resource-agnostic transfer template +- Transfer button on all supported detail pages +- Index page highlight behavior +- Prompt file verified/added for transfer conversions +- Tests + minimal docs update diff --git a/plans/gallery-enhancement-plan.md b/plans/gallery-enhancement-plan.md new file mode 100644 index 0000000..e4eb29d --- /dev/null +++ b/plans/gallery-enhancement-plan.md @@ -0,0 +1,775 @@ +# GAZE Gallery Enhancement Plan + +## Executive Summary + +This plan outlines a comprehensive enhancement to the GAZE Image Gallery, transforming it from a basic grid-and-lightbox viewer into an immersive, multi-modal viewing experience. The enhancement introduces **three major feature categories**: Slideshow Modes, Image Wall Layouts, and Interactive Viewing Tools. + +--- + +## Current State Analysis + +### Existing Implementation + +| Component | Current State | File Location | +|-----------|--------------|---------------| +| Grid View | Auto-fill grid, 160-210px cards, hover overlays | [`templates/gallery.html`](templates/gallery.html:88) | +| Lightbox | Bootstrap modal, arrow navigation, keyboard support | [`templates/layout.html`](templates/layout.html:123) | +| Filtering | Category, item, sort, pagination | [`templates/gallery.html`](templates/gallery.html:11) | +| Metadata | Prompt modal with LoRA chips, generation params | [`templates/gallery.html`](templates/gallery.html:194) | + +### Design System Variables + +```css +/* From static/style.css — these will guide our new components */ +--bg-base: #07070f; +--bg-card: #0c0c1c; +--accent: #8b7eff; +--accent-glow: rgba(139, 126, 255, 0.14); +--border: #16163a; +--text: #e8e8f5; +--text-muted: #6a6a9a; +--radius: 12px; +--font-display: 'Space Grotesk'; +--font-body: 'Inter'; +``` + +--- + +## Architecture Overview + +```mermaid +graph TB + subgraph Gallery Page + A[View Mode Selector] --> B[Grid View] + A --> C[Masonry View] + A --> D[Justified View] + A --> E[Collage View] + end + + subgraph Viewer Modes + F[Cinema Slideshow] + G[Classic Slideshow] + H[Showcase Frame] + I[Comparison Mode] + J[Discovery Mode] + K[Ambient Screensaver] + end + + subgraph Core Engine + L[GalleryController] + M[ImageLoader - lazy/preload] + N[TransitionEngine] + O[MetadataProvider] + end + + B & C & D & E --> L + L --> F & G & H & I & J & K + L --> M + L --> N + L --> O +``` + +--- + +## Feature Specifications + +### 1. View Mode Selector + +A floating toolbar that allows switching between gallery layouts. + +**UI Design:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ [≡ Grid] [◫ Masonry] [▬ Justified] [◱ Collage] │ [▶ Slideshow ▾] │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Slideshow Dropdown:** +``` +┌──────────────────┐ +│ 🎬 Cinema │ +│ ⏯ Classic │ +│ 🖼 Showcase │ +│ ⚡ Discovery │ +│ 🌌 Ambient │ +│ ⚖ Comparison │ +└──────────────────┘ +``` + +--- + +### 2. Image Wall Layouts + +#### 2.1 Masonry Layout (Pinterest-style) + +Variable height columns that create a waterfall effect. + +``` +┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ +│ │ │ │ │ │ │ │ +│ 1 │ │ 2 │ │ │ │ 4 │ +│ │ └──────┘ │ 3 │ │ │ +└──────┘ ┌──────┐ │ │ └──────┘ +┌──────┐ │ │ │ │ ┌──────┐ +│ 5 │ │ 6 │ └──────┘ │ │ +└──────┘ │ │ ┌──────┐ │ 8 │ +┌──────┐ └──────┘ │ 7 │ │ │ +│ 9 │ ┌──────┐ └──────┘ └──────┘ +└──────┘ │ 10 │ + └──────┘ +``` + +**Technical Approach:** +- CSS columns or CSS Grid with `grid-auto-rows: 1px` and `span` calculation +- JavaScript to calculate optimal column count based on viewport +- Preserve natural aspect ratios of images +- Lazy loading with Intersection Observer + +**CSS Variables:** +```css +--masonry-column-width: 280px; +--masonry-gap: 12px; +--masonry-min-columns: 2; +--masonry-max-columns: 6; +``` + +#### 2.2 Justified Layout (Google Photos-style) + +Rows with perfect edge alignment, variable widths. + +``` +┌────────────┬──────────┬─────────────────┐ +│ 1 │ 2 │ 3 │ +└────────────┴──────────┴─────────────────┘ +┌─────────────────┬────────────┬─────────┐ +│ 4 │ 5 │ 6 │ +└─────────────────┴────────────┴─────────┘ +┌──────────┬─────────────────┬───────────┐ +│ 7 │ 8 │ 9 │ +└──────────┴─────────────────┴───────────┘ +``` + +**Technical Approach:** +- Linear partition algorithm to distribute images across rows +- Target row height with flex-grow distribution +- Smart breakpoints based on aspect ratios +- Each row is a flex container + +**Configuration:** +```javascript +{ + targetRowHeight: 240, + minRowHeight: 180, + maxRowHeight: 320, + containerPadding: 16, + imageSpacing: 8 +} +``` + +#### 2.3 Collage Layout (Magazine-style) + +Creative mixed-size layout with featured images. + +``` +┌───────────────────┬─────────┐ +│ │ 2 │ +│ 1 ├─────────┤ +│ FEATURED │ 3 │ +│ ├─────────┤ +├─────────┬─────────┤ 4 │ +│ 5 │ 6 │ │ +└─────────┴─────────┴─────────┘ +``` + +**Technical Approach:** +- CSS Grid with named areas +- Template patterns that rotate/cycle +- Special "featured" designation based on: + - User favorites (if implemented) + - Most recent + - Random selection +- Subtle rotation transforms (-2° to +2°) on some tiles +- Optional overlap with z-index layering + +**Template Patterns:** +```javascript +const collagePatterns = [ + 'hero-right', // Large left, stack right + 'hero-left', // Stack left, large right + 'hero-center', // Center featured, corners fill + 'magazine', // Asymmetric editorial style + 'scattered' // Random positions with overlap +]; +``` + +--- + +### 3. Slideshow Modes + +#### 3.1 Cinema Mode + +Full-screen immersive experience with cinematic effects. + +**Features:** +- **Ambient glow**: Extract dominant colors from image, project as soft backdrop glow +- **Ken Burns effect**: Subtle pan/zoom animation on images +- **Vignette overlay**: Soft dark edges for focus +- **Particle system**: Optional floating light particles/orbs +- **Audio reactive**: Optional ambient music visualization (future) + +**Visual Design:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░┌─────────────────────────────────────────────┐░░░░░░░░░░░░│ +│░░░░░░│ │░░░░░░░░░░░░│ +│░░░░░░│ [Image with Ken Burns] │░░░░░░░░░░░░│ +│░░░░░░│ │░░░░░░░░░░░░│ +│░░░░░░│ │░░░░░░░░░░░░│ +│░░░░░░└─────────────────────────────────────────────┘░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░ ● ● ● ○ ○ ○ ○ ○ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░ 4 / 24 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░ = Ambient glow from image colors ░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Controls (appear on hover/touch):** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⏮ ⏯ ⏭ │ ⏱ 5s ▾ │ 🔀 Shuffle │ ℹ Info │ ✕ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Ken Burns Patterns:** +```javascript +const kenBurnsPatterns = [ + { start: 'scale(1) translate(0, 0)', end: 'scale(1.15) translate(-3%, -2%)' }, + { start: 'scale(1.1) translate(-5%, 0)', end: 'scale(1) translate(0, 0)' }, + { start: 'scale(1) translate(0, 5%)', end: 'scale(1.12) translate(2%, -3%)' }, + // ... more patterns +]; +``` + +**Ambient Glow Implementation:** +```javascript +// Use canvas to extract dominant colors +async function extractAmbientColors(imageSrc) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = await loadImage(imageSrc); + + canvas.width = 50; // Downscale for performance + canvas.height = 50; + ctx.drawImage(img, 0, 0, 50, 50); + + const imageData = ctx.getImageData(0, 0, 50, 50); + // Quantize colors and find dominant clusters + return dominantColors(imageData); // Returns [primary, secondary, accent] +} +``` + +#### 3.2 Classic Slideshow + +Clean, professional presentation mode. + +**Features:** +- Multiple transition types: fade, slide, zoom, cube +- Progress bar or dot indicators +- Timer controls (3s, 5s, 8s, 15s, manual) +- Loop toggle +- Random/sequential order + +**UI:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ ┌───────────────┐ │ +│ ◀ │ │ ▶ │ +│ │ IMAGE │ │ +│ │ │ │ +│ └───────────────┘ │ +│ │ +│ ● ● ● ○ ○ ○ ○ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ ⏮ ⏯ ⏭ 🔀 Shuffle ⏱ 5s ✕ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Transitions CSS:** +```css +.slide-transition-fade { transition: opacity 0.8s ease; } +.slide-transition-slide { transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); } +.slide-transition-zoom { transition: transform 0.7s ease, opacity 0.7s ease; } +.slide-transition-cube { + transform-style: preserve-3d; + perspective: 1000px; + transition: transform 0.8s ease; +} +``` + +#### 3.3 Showcase Frame Mode + +Digital picture frame aesthetic with metadata. + +**Features:** +- Image displayed with decorative frame/mat +- Metadata panel (character name, category, date) +- Ambient room simulation (optional) +- Clock widget (optional) +- Subtle animations + +**Layout:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ ╔══════════════════════════════════════════════╗ │ +│ ║ ┌──────────────────────────────────────────┐ ║ │ +│ ║ │ │ ║ │ +│ ║ │ │ ║ │ +│ ║ │ IMAGE │ ║ │ +│ ║ │ │ ║ │ +│ ║ │ │ ║ │ +│ ║ └──────────────────────────────────────────┘ ║ │ +│ ╚══════════════════════════════════════════════╝ │ +│ │ +│ Character: Luna Eclipse │ +│ Category: characters • Style: anime │ +│ Created: March 8, 2026 │ +│ 12:34 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Frame Styles:** +```javascript +const frameStyles = [ + 'minimal', // Thin elegant border + 'classic', // Traditional wooden frame look + 'modern', // Thick black mat, thin metal frame + 'gallery', // White mat, thin black frame + 'floating', // Shadow box effect + 'none' // No frame, edge-to-edge +]; +``` + +#### 3.4 Discovery Mode (Random Shuffle) + +Explore your collection with serendipity. + +**Features:** +- Random image selection with no repeats until all shown +- "Like" button to save favorites (session-based) +- Quick category jump +- Surprise effects (occasional zoom, pan variations) +- Stats display ("You've discovered 47 of 234 images") + +**Controls:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⚡ DISCOVERY MODE │ +│ │ +│ ┌───────────────────┐ │ +│ │ │ │ +│ │ IMAGE │ │ +│ │ │ │ +│ └───────────────────┘ │ +│ │ +│ ❤️ Like 🔀 Next Random ⏯ Auto │ +│ │ +│ Discovered: 47 / 234 ━━━━━━━░░░░░░░░░░░░ │ +│ │ +│ Quick jump: [Characters] [Actions] [Outfits] [Scenes] [All] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 3.5 Ambient Screensaver Mode + +Artistic background display with effects. + +**Features:** +- Fullscreen with auto-hide UI +- Particle effects (floating light orbs, subtle sparkles) +- Slow crossfade transitions (10-30 seconds) +- Color-matched gradient backgrounds +- Clock overlay option +- Wake on mouse move + +**Particle System:** +```javascript +const particleConfig = { + count: 50, + colors: ['#8b7eff', '#c084fc', '#60a5fa', '#ffffff'], + minSize: 2, + maxSize: 8, + speed: 0.3, + opacity: [0.1, 0.6], + blur: [0, 4], + drift: true // Slight random movement +}; +``` + +**Visual Effect:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ · · · │ +│ · · · │ +│ · ┌─────────────────┐ · │ +│ · │ │ · │ +│ · │ IMAGE │ · · │ +│ │ │ · │ +│ · └─────────────────┘ · │ +│ · · · · · │ +│ · · · │ +│ 12:34 │ +│ · = floating light particles │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 3.6 Comparison Mode + +Side-by-side or overlay comparison. + +**Features:** +- Two image selection +- Slider overlay (before/after style) +- Side-by-side split +- Onion skin overlay (opacity blend) +- Difference highlight mode +- Zoom sync (both images zoom together) + +**Layouts:** +``` +SLIDER MODE: SPLIT MODE: +┌─────────────────────────────┐ ┌───────────┬───────────┐ +│ │ │ │ │ │ +│ IMAGE A │ IMAGE B │ │ IMAGE A │ IMAGE B │ +│ │ │ │ │ │ +│ ◄═══|═══► │ │ │ │ +│ │ │ │ │ │ +└─────────────────────────────┘ └───────────┴───────────┘ + +ONION SKIN: DIFFERENCE: +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ │ │ │ +│ IMAGE A + B BLENDED │ │ PIXEL DIFFERENCE VIEW │ +│ │ │ (highlights changes) │ +│ Opacity: ●━━━━━━━○ │ │ │ +│ │ │ │ +└─────────────────────────────┘ └─────────────────────────────┘ +``` + +--- + +### 4. Enhanced Lightbox Viewer + +Upgrade the existing lightbox with professional features. + +#### 4.1 Zoom & Pan Controls + +**Features:** +- Mouse wheel zoom +- Double-click to fit/fill toggle +- Click-and-drag panning when zoomed +- Pinch-to-zoom on touch devices +- Zoom level indicator +- Reset button + +**Implementation:** +```javascript +class ZoomPanController { + constructor(element) { + this.scale = 1; + this.translateX = 0; + this.translateY = 0; + this.minScale = 0.5; + this.maxScale = 5; + } + + handleWheel(e) { + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * delta)); + // Zoom toward cursor position + this.zoomToPoint(newScale, e.clientX, e.clientY); + } + + handleDrag(e) { + if (this.scale > 1) { + this.translateX += e.movementX; + this.translateY += e.movementY; + this.applyTransform(); + } + } +} +``` + +#### 4.2 Info Overlay Panel + +Slide-out panel with image details. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ┌───────────┐│ +│ │ℹ INFO ││ +│ ├───────────┤│ +│ ┌─────────────────────┐ │ ││ +│ │ │ │ Luna ││ +│ │ IMAGE │ │ Eclipse ││ +│ │ │ │ ││ +│ │ │ │ Category: ││ +│ └─────────────────────┘ │ characters││ +│ │ ││ +│ │ Actions: ││ +│ │ standing ││ +│ │ ││ +│ │ [Prompt] ││ +│ │ [Generate]││ +│ └───────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### 4.3 Thumbnail Strip + +Quick navigation with visual preview. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌─────────────────┐ │ +│ │ │ │ +│ │ MAIN │ │ +│ │ IMAGE │ │ +│ │ │ │ +│ └─────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ▢ ▢ ▢ [▣] ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ ▢ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 5. Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `←` / `→` | Previous / Next image | +| `↑` / `↓` | Zoom in / out | +| `Space` | Toggle slideshow play/pause | +| `F` | Toggle fullscreen | +| `I` | Toggle info panel | +| `G` | Open grid view | +| `M` | Toggle masonry view | +| `S` | Start slideshow | +| `C` | Enter comparison mode | +| `R` | Random image | +| `Esc` | Close viewer / exit fullscreen | +| `1-5` | Set slideshow speed preset | +| `+` / `-` | Zoom in / out | +| `0` | Reset zoom | + +--- + +### 6. Mobile & Touch Optimization + +**Gestures:** +- **Swipe left/right**: Navigate images +- **Swipe up**: Show info panel +- **Swipe down**: Close viewer +- **Pinch**: Zoom +- **Double-tap**: Toggle fit/fill +- **Long press**: Show context menu + +**Responsive Breakpoints:** +```css +/* Mobile - single column, touch optimized */ +@media (max-width: 576px) { } + +/* Tablet - 2-3 columns */ +@media (min-width: 577px) and (max-width: 991px) { } + +/* Desktop - 4-6 columns */ +@media (min-width: 992px) { } + +/* Large displays - up to 8 columns */ +@media (min-width: 1400px) { } +``` + +--- + +## File Structure + +``` +static/ +├── style.css # Add gallery enhancement styles +├── js/ +│ └── gallery/ +│ ├── gallery-core.js # Main controller +│ ├── layout-masonry.js # Masonry layout engine +│ ├── layout-justified.js # Justified layout engine +│ ├── layout-collage.js # Collage layout engine +│ ├── slideshow-cinema.js # Cinema mode +│ ├── slideshow-classic.js # Classic slideshow +│ ├── slideshow-showcase.js # Showcase frame +│ ├── mode-discovery.js # Discovery mode +│ ├── mode-ambient.js # Ambient screensaver +│ ├── mode-comparison.js # Comparison mode +│ ├── viewer-zoom.js # Zoom/pan controller +│ ├── effects-particles.js # Particle system +│ ├── effects-ambient.js # Ambient color extraction +│ └── effects-kenburns.js # Ken Burns animations +│ +templates/ +├── gallery.html # Enhanced with view mode selector +└── partials/ + └── gallery-viewer.html # New standalone viewer partial +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation +- [ ] Create gallery core controller class +- [ ] Implement view mode selector UI +- [ ] Add masonry layout with lazy loading +- [ ] Enhanced lightbox with zoom/pan +- [ ] Keyboard shortcut system + +### Phase 2: Layouts +- [ ] Justified gallery layout +- [ ] Collage layout with templates +- [ ] Thumbnail strip navigation +- [ ] Mobile swipe gestures + +### Phase 3: Slideshows +- [ ] Classic slideshow with transitions +- [ ] Cinema mode with ambient glow +- [ ] Ken Burns effect engine +- [ ] Showcase frame mode + +### Phase 4: Advanced Features +- [ ] Discovery mode with favorites +- [ ] Comparison mode slider +- [ ] Ambient screensaver with particles +- [ ] Info panel with metadata + +### Phase 5: Polish +- [ ] Performance optimization +- [ ] Accessibility improvements +- [ ] Settings persistence +- [ ] Documentation + +--- + +## Technical Considerations + +### Performance +- Use `IntersectionObserver` for lazy loading +- Preload next 2 images in slideshow +- Use `requestAnimationFrame` for animations +- Debounce resize handlers +- Use CSS transforms instead of layout properties +- Consider web workers for color extraction + +### Browser Support +- Modern browsers (Chrome, Firefox, Safari, Edge) +- CSS Grid and Flexbox required +- Fullscreen API +- Intersection Observer API +- Web Animations API preferred + +### Accessibility +- ARIA labels on all controls +- Focus management in modals +- Reduced motion preference respected +- Screen reader announcements for image changes +- Keyboard navigation throughout + +--- + +## CSS Variables for Gallery Enhancement + +```css +/* Gallery Enhancement Variables */ +:root { + /* Layouts */ + --gallery-gap: 8px; + --gallery-card-radius: 12px; + --gallery-masonry-width: 280px; + --gallery-justified-height: 240px; + + /* Viewer */ + --viewer-bg: rgba(0, 0, 0, 0.97); + --viewer-accent: var(--accent); + --viewer-transition: 0.3s ease; + + /* Slideshow */ + --slideshow-interval: 5000; + --kenburns-duration: 8000; + --transition-duration: 800; + + /* Effects */ + --ambient-blur: 100px; + --ambient-opacity: 0.3; + --particle-count: 50; + --vignette-opacity: 0.4; + + /* Frames */ + --frame-color-light: #f5f5f5; + --frame-color-dark: #1a1a2e; + --mat-width: 40px; +} +``` + +--- + +## Mockup: Cinema Mode + +``` +╔═══════════════════════════════════════════════════════════════════════╗ +║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ +║▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓▓║ +║▓▓▓▓▓░░░░░░ ░░░░░▓▓▓▓▓▓║ +║▓▓░░░░░ ░░░░▓▓▓║ +║▓░░░░ ╭─────────────────────────────╮ ░░░▓║ +║░░░░ │ │ ░░║ +║░░ │ │ ░║ +║░ │ ★ IMAGE ★ │ ░║ +║░ │ │ ░║ +║░ │ │ ░║ +║░░ │ │ ░░║ +║░░░ ╰─────────────────────────────╯ ░░░║ +║▓░░░░ ░░▓▓║ +║▓▓░░░░░ • • • ○ ○ ░░░░▓▓▓║ +║▓▓▓▓░░░░░░░ 4 / 24 ░░░░░▓▓▓▓▓▓║ +║▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓║ +║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ +╚═══════════════════════════════════════════════════════════════════════╝ + +Legend: +▓ = Darkest vignette +░ = Ambient glow (color-matched to image) +★ = Image with Ken Burns animation +• = Active indicator, ○ = Inactive + +Controls appear on hover: + ┌──────────────────────────────────────────────────────────────────────┐ + │ ⏮ │ ⏸ │ ⏭ │ ⏱ 5s ▾ │ 🔀 Shuffle │ ℹ️ Info │ ✕ │ + └──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Next Steps + +1. **Review and approve** this plan +2. **Prioritize features** for MVP +3. **Begin Phase 1** implementation +4. **Iterate** based on feedback + +Would you like me to proceed with implementation, or would you like to adjust any aspects of this plan? diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e87784c --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,33 @@ +def register_routes(app): + """Register all route modules with the Flask app.""" + from routes import queue_api + from routes import settings + from routes import characters + from routes import outfits + from routes import actions + from routes import styles + from routes import scenes + from routes import detailers + from routes import checkpoints + from routes import looks + from routes import presets + from routes import generator + from routes import gallery + from routes import strengths + from routes import transfer + + queue_api.register_routes(app) + settings.register_routes(app) + characters.register_routes(app) + outfits.register_routes(app) + actions.register_routes(app) + styles.register_routes(app) + scenes.register_routes(app) + detailers.register_routes(app) + checkpoints.register_routes(app) + looks.register_routes(app) + presets.register_routes(app) + generator.register_routes(app) + gallery.register_routes(app) + strengths.register_routes(app) + transfer.register_routes(app) diff --git a/routes/actions.py b/routes/actions.py new file mode 100644 index 0000000..4ca8c1f --- /dev/null +++ b/routes/actions.py @@ -0,0 +1,617 @@ +import json +import os +import re +import time +import random +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +from sqlalchemy.orm.attributes import flag_modified + +from models import db, Character, Action, Outfit, Style, Scene, Detailer, Checkpoint, Settings, Look +from services.workflow import _prepare_workflow, _get_default_checkpoint +from services.job_queue import _enqueue_job, _make_finalize +from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background +from services.sync import sync_actions +from services.file_io import get_available_loras +from services.llm import load_prompt, call_llm +from utils import allowed_file, _LORA_DEFAULTS + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/get_missing_actions') + def get_missing_actions(): + missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all() + return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]} + + @app.route('/clear_all_action_covers', methods=['POST']) + def clear_all_action_covers(): + actions = Action.query.all() + for action in actions: + action.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/actions') + def actions_index(): + actions = Action.query.order_by(Action.name).all() + return render_template('actions/index.html', actions=actions) + + @app.route('/actions/rescan', methods=['POST']) + def rescan_actions(): + sync_actions() + flash('Database synced with action files.') + return redirect(url_for('actions_index')) + + @app.route('/action/') + def action_detail(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Load state from session + preferences = session.get(f'prefs_action_{slug}') + preview_image = session.get(f'preview_action_{slug}') + selected_character = session.get(f'char_action_{slug}') + extra_positive = session.get(f'extra_pos_action_{slug}', '') + extra_negative = session.get(f'extra_neg_action_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"actions/{slug}/{f}" for f in files] + + return render_template('actions/detail.html', action=action, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, existing_previews=existing_previews, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/action//edit', methods=['GET', 'POST']) + def edit_action(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('actions') + + if request.method == 'POST': + try: + # 1. Update basic fields + action.name = request.form.get('action_name') + + # 2. Rebuild the data dictionary + new_data = action.data.copy() + new_data['action_name'] = action.name + + # Update action_id if provided + new_action_id = request.form.get('action_id', action.action_id) + new_data['action_id'] = new_action_id + + # Update action section + if 'action' in new_data: + for key in new_data['action'].keys(): + form_key = f"action_{key}" + if form_key in request.form: + new_data['action'][key] = request.form.get(form_key) + + # Update lora section + if 'lora' in new_data: + for key in new_data['lora'].keys(): + form_key = f"lora_{key}" + if form_key in request.form: + val = request.form.get(form_key) + if key == 'lora_weight': + try: val = float(val) + except: val = 1.0 + new_data['lora'][key] = val + + # LoRA weight randomization bounds + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + 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] + + action.data = new_data + flag_modified(action, "data") + + # 3. Write back to JSON file + action_file = action.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', action.action_id)}.json" + file_path = os.path.join(app.config['ACTIONS_DIR'], action_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Action profile updated successfully!') + return redirect(url_for('action_detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('actions/edit.html', action=action, loras=loras) + + @app.route('/action//upload', methods=['POST']) + def upload_action_image(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create action subfolder + action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") + os.makedirs(action_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(action_folder, filename) + file.save(file_path) + + # Store relative path in DB + action.image_path = f"actions/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('action_detail', slug=slug)) + + @app.route('/action//generate', methods=['POST']) + def generate_action_image(slug): + action_obj = Action.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get selected character (if any) + character_slug = request.form.get('character_slug', '') + character = None + + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'char_action_{slug}'] = character_slug + session[f'prefs_action_{slug}'] = selected_fields + session[f'extra_pos_action_{slug}'] = extra_positive + session[f'extra_neg_action_{slug}'] = extra_negative + session.modified = True + + # Build combined data for prompt building + if character: + # Combine character identity/wardrobe with action details + # Action details replace character's 'defaults' (pose, etc.) + combined_data = character.data.copy() + + # Update 'defaults' with action details + action_data = action_obj.data.get('action', {}) + combined_data['action'] = action_data # Ensure action section is present for routing + combined_data['participants'] = action_obj.data.get('participants', {}) # Add participants + + # Aggregate pose-related fields into 'pose' + pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] + pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] + + # Aggregate expression-related fields into 'expression' + expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] + + combined_data['defaults'] = { + 'pose': ", ".join(pose_parts), + 'expression': ", ".join(expression_parts), + 'scene': action_data.get('additional', '') + } + + # Merge lora triggers if present + action_lora = action_obj.data.get('lora', {}) + if action_lora.get('lora_triggers'): + if 'lora' not in combined_data: combined_data['lora'] = {} + combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {action_lora['lora_triggers']}" + + # Merge tags + combined_data['tags'] = list(set(combined_data.get('tags', []) + action_obj.data.get('tags', []))) + + # Use action's defaults if no manual selection + if not selected_fields: + selected_fields = list(action_obj.default_fields) if action_obj.default_fields else [] + + # Auto-include essential character fields if a character is selected + if selected_fields: + _ensure_character_fields(character, selected_fields) + else: + # Fallback to sensible defaults if still empty (no checkboxes and no action defaults) + selected_fields = ['special::name', 'defaults::pose', 'defaults::expression'] + # Add identity fields + for key in ['base_specs', 'hair', 'eyes']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + # Add wardrobe fields + wardrobe = character.get_active_wardrobe() + for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + if wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + + default_fields = action_obj.default_fields + active_outfit = character.active_outfit + else: + # Action only - no character (rarely makes sense for actions but let's handle it) + action_data = action_obj.data.get('action', {}) + + # Aggregate pose-related fields into 'pose' + pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet'] + pose_parts = [action_data.get(k) for k in pose_fields if action_data.get(k)] + + # Aggregate expression-related fields into 'expression' + expression_parts = [action_data.get(k) for k in ['head', 'eyes'] if action_data.get(k)] + + combined_data = { + 'character_id': action_obj.action_id, + 'defaults': { + 'pose': ", ".join(pose_parts), + 'expression': ", ".join(expression_parts), + 'scene': action_data.get('additional', '') + }, + 'lora': action_obj.data.get('lora', {}), + 'tags': action_obj.data.get('tags', []) + } + if not selected_fields: + selected_fields = ['defaults::pose', 'defaults::expression', 'defaults::scene', 'lora::lora_triggers', 'special::tags'] + default_fields = action_obj.default_fields + active_outfit = 'default' + + # Queue generation + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + # Build prompts for combined data + prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) + + # Handle multiple female characters + participants = action_obj.data.get('participants', {}) + orientation = participants.get('orientation', '') + f_count = orientation.upper().count('F') + + if f_count > 1: + # We need f_count - 1 additional characters + num_extras = f_count - 1 + + # Get all characters excluding the current one + query = Character.query + if character: + query = query.filter(Character.id != character.id) + all_others = query.all() + + if len(all_others) >= num_extras: + extras = random.sample(all_others, num_extras) + + for extra_char in extras: + extra_parts = [] + + # Identity + ident = extra_char.data.get('identity', {}) + for key in ['base_specs', 'hair', 'eyes', 'extra']: + val = ident.get(key) + if val: + # Remove 1girl/solo + val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ') + extra_parts.append(val) + + # Wardrobe (active outfit) + wardrobe = extra_char.get_active_wardrobe() + for key in ['top', 'headwear', 'legwear', 'footwear', 'accessories']: + val = wardrobe.get(key) + if val: + extra_parts.append(val) + + # Append to main prompt + if extra_parts: + prompts["main"] += ", " + ", ".join(extra_parts) + print(f"Added extra character: {extra_char.name}") + + _append_background(prompts, character) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Prepare workflow + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, action=action_obj, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + + char_label = character.name if character else 'no character' + label = f"Action: {action_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + return redirect(url_for('action_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('action_detail', slug=slug)) + + @app.route('/action//replace_cover_from_preview', methods=['POST']) + def replace_action_cover_from_preview(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + action.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('action_detail', slug=slug)) + + @app.route('/action//save_defaults', methods=['POST']) + def save_action_defaults(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + action.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this action!') + return redirect(url_for('action_detail', slug=slug)) + + @app.route('/actions/bulk_create', methods=['POST']) + def bulk_create_actions_from_loras(): + _s = Settings.query.first() + actions_lora_dir = ((_s.lora_dir_actions if _s else None) or '/ImageModels/lora/Illustrious/Poses').rstrip('/') + _lora_subfolder = os.path.basename(actions_lora_dir) + if not os.path.exists(actions_lora_dir): + flash('Actions LoRA directory not found.', 'error') + return redirect(url_for('actions_index')) + + overwrite = request.form.get('overwrite') == 'true' + created_count = 0 + skipped_count = 0 + overwritten_count = 0 + + system_prompt = load_prompt('action_system.txt') + if not system_prompt: + flash('Action system prompt file not found.', 'error') + return redirect(url_for('actions_index')) + + for filename in os.listdir(actions_lora_dir): + if filename.endswith('.safetensors'): + name_base = filename.rsplit('.', 1)[0] + action_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) + action_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() + + 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_count += 1 + continue + + html_filename = f"{name_base}.html" + html_path = os.path.join(actions_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() + # 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}") + + 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###" + + llm_response = call_llm(prompt, system_prompt) + + # Clean response + 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 + + # Update lora dict safely + if 'lora' not in action_data: action_data['lora'] = {} + action_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" + + # Fallbacks if LLM failed to extract metadata + if not action_data['lora'].get('lora_triggers'): + action_data['lora']['lora_triggers'] = name_base + 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: + action_data['lora']['lora_weight_min'] = 0.7 + 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: + json.dump(action_data, f, indent=2) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + # Small delay to avoid API rate limits if many files + time.sleep(0.5) + + except Exception as e: + print(f"Error creating action for {filename}: {e}") + + 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.') + + return redirect(url_for('actions_index')) + + @app.route('/action/create', methods=['GET', 'POST']) + def create_action(): + 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' + + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'action' + + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + if use_llm: + if not prompt: + flash("Description is required when AI generation is enabled.") + return redirect(request.url) + + system_prompt = load_prompt('action_system.txt') + if not system_prompt: + flash("Action system prompt file not found.") + return redirect(request.url) + + try: + llm_response = call_llm(f"Create an action profile for '{name}' based on this description: {prompt}", system_prompt) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + action_data = json.loads(clean_json) + action_data['action_id'] = safe_slug + action_data['action_name'] = name + except Exception as e: + print(f"LLM error: {e}") + flash(f"Failed to generate action profile: {e}") + return redirect(request.url) + else: + action_data = { + "action_id": safe_slug, + "action_name": name, + "action": { + "full_body": "", "head": "", "eyes": "", "arms": "", "hands": "", + "torso": "", "pelvis": "", "legs": "", "feet": "", "additional": "" + }, + "lora": {"lora_name": "", "lora_weight": 1.0, "lora_triggers": ""}, + "tags": [] + } + + try: + file_path = os.path.join(app.config['ACTIONS_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(action_data, f, indent=2) + + new_action = Action( + action_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", + name=name, data=action_data + ) + db.session.add(new_action) + db.session.commit() + + flash('Action created successfully!') + return redirect(url_for('action_detail', slug=safe_slug)) + 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') + + @app.route('/action//clone', methods=['POST']) + def clone_action(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + + # Find the next available number for the clone + base_id = action.action_id + match = re.match(r'^(.+?)_(\d+)$', base_id) + if match: + base_name = match.group(1) + current_num = int(match.group(2)) + else: + base_name = base_id + current_num = 1 + + next_num = current_num + 1 + while True: + new_id = f"{base_name}_{next_num:02d}" + new_filename = f"{new_id}.json" + new_path = os.path.join(app.config['ACTIONS_DIR'], new_filename) + if not os.path.exists(new_path): + break + next_num += 1 + + new_data = action.data.copy() + new_data['action_id'] = new_id + new_data['action_name'] = f"{action.name} (Copy)" + + with open(new_path, 'w') as f: + json.dump(new_data, f, indent=2) + + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + new_action = Action( + action_id=new_id, slug=new_slug, filename=new_filename, + name=new_data['action_name'], data=new_data + ) + db.session.add(new_action) + db.session.commit() + + flash(f'Action cloned as "{new_id}"!') + return redirect(url_for('action_detail', slug=new_slug)) + + @app.route('/action//save_json', methods=['POST']) + def save_action_json(slug): + action = Action.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + action.data = new_data + flag_modified(action, 'data') + db.session.commit() + if action.filename: + file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} diff --git a/routes/characters.py b/routes/characters.py new file mode 100644 index 0000000..1d3e520 --- /dev/null +++ b/routes/characters.py @@ -0,0 +1,873 @@ +import json +import logging +import os +import re + +from flask import flash, jsonify, redirect, render_template, request, session, url_for +from sqlalchemy.orm.attributes import flag_modified +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.prompts import build_prompt +from services.sync import sync_characters +from services.workflow import _get_default_checkpoint, _prepare_workflow +from utils import allowed_file + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/') + def index(): + characters = Character.query.order_by(Character.name).all() + return render_template('index.html', characters=characters) + + @app.route('/rescan', methods=['POST']) + def rescan(): + sync_characters() + flash('Database synced with character files.') + return redirect(url_for('index')) + + @app.route('/character/') + def detail(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + all_outfits = Outfit.query.order_by(Outfit.name).all() + outfit_map = {o.outfit_id: o for o in all_outfits} + + # Helper function for template to get outfit by ID + def get_outfit_by_id(outfit_id): + return outfit_map.get(outfit_id) + + # Load state from session + preferences = session.get(f'prefs_{slug}') + preview_image = session.get(f'preview_{slug}') + extra_positive = session.get(f'extra_pos_{slug}', '') + extra_negative = session.get(f'extra_neg_{slug}', '') + + return render_template('detail.html', character=character, preferences=preferences, preview_image=preview_image, all_outfits=all_outfits, outfit_map=outfit_map, get_outfit_by_id=get_outfit_by_id, extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/character//transfer', methods=['GET', 'POST']) + def transfer_character(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + + if request.method == 'POST': + target_type = request.form.get('target_type') + new_name = request.form.get('new_name', '').strip() + use_llm = request.form.get('use_llm') == 'on' + + if not new_name: + flash('New name is required for transfer') + return redirect(url_for('transfer_character', slug=slug)) + + # Validate new name length and content + if len(new_name) > 100: + flash('New name must be 100 characters or less') + return redirect(url_for('transfer_character', slug=slug)) + + # Validate target type + VALID_TARGET_TYPES = {'look', 'outfit', 'action', 'style', 'scene', 'detailer'} + if target_type not in VALID_TARGET_TYPES: + flash('Invalid target type') + return redirect(url_for('transfer_character', slug=slug)) + + # Generate new slug from name + new_slug = re.sub(r'[^a-zA-Z0-9]+', '_', new_name.lower()).strip('_') + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_slug) + if not safe_slug: + safe_slug = 'transferred' + + # Find available filename + base_slug = safe_slug + counter = 1 + target_dir = None + model_class = None + + # Map target type to directory and model + if target_type == 'look': + target_dir = app.config['LOOKS_DIR'] + model_class = Look + id_field = 'look_id' + elif target_type == 'outfit': + target_dir = app.config['CLOTHING_DIR'] + model_class = Outfit + id_field = 'outfit_id' + elif target_type == 'action': + target_dir = app.config['ACTIONS_DIR'] + model_class = Action + id_field = 'action_id' + elif target_type == 'style': + target_dir = app.config['STYLES_DIR'] + model_class = Style + id_field = 'style_id' + elif target_type == 'scene': + target_dir = app.config['SCENES_DIR'] + model_class = Scene + id_field = 'scene_id' + elif target_type == 'detailer': + target_dir = app.config['DETAILERS_DIR'] + model_class = Detailer + id_field = 'detailer_id' + else: + flash('Invalid target type') + return redirect(url_for('transfer_character', slug=slug)) + + # Check for existing file + while os.path.exists(os.path.join(target_dir, f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + if use_llm: + # Use LLM to regenerate JSON for new type + try: + # Create prompt for LLM to convert character to target type + system_prompt = load_prompt('transfer_system.txt') + if not system_prompt: + system_prompt = f"""You are an AI assistant that converts character profiles to {target_type} profiles. + + Convert the following character profile into a {target_type} profile. + A {target_type} should focus on {target_type}-specific details. + Keep the core identity but adapt it for the new context. + Return only valid JSON with no markdown formatting.""" + + # Prepare character data for LLM + char_summary = json.dumps(character.data, indent=2) + llm_prompt = f"""Convert this character profile to a {target_type} profile: + + Original character name: {character.name} + Target {target_type} name: {new_name} + + Character data: + {char_summary} + + Create a new {target_type} JSON structure appropriate for {target_type}s.""" + + llm_response = call_llm(llm_prompt, system_prompt) + + # Clean response + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + new_data = json.loads(clean_json) + + # Ensure required fields + new_data[f'{target_type}_id'] = safe_slug + new_data[f'{target_type}_name'] = new_name + + except Exception as e: + print(f"LLM transfer error: {e}") + flash(f'Failed to generate {target_type} with AI: {e}') + return redirect(url_for('transfer_character', slug=slug)) + else: + # Create blank template for target type + new_data = { + f'{target_type}_id': safe_slug, + f'{target_type}_name': new_name, + 'description': f'Transferred from character: {character.name}', + 'tags': character.data.get('tags', []), + 'lora': character.data.get('lora', {}) + } + + try: + # Save new file + file_path = os.path.join(target_dir, f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + # Create database entry + new_entity = model_class( + **{id_field: safe_slug}, + slug=safe_slug, + filename=f"{safe_slug}.json", + name=new_name, + data=new_data + ) + db.session.add(new_entity) + db.session.commit() + + flash(f'Successfully transferred to {target_type}: {new_name}') + + # Redirect to new entity's detail page + if target_type == 'look': + return redirect(url_for('look_detail', slug=safe_slug)) + elif target_type == 'outfit': + return redirect(url_for('outfit_detail', slug=safe_slug)) + elif target_type == 'action': + return redirect(url_for('action_detail', slug=safe_slug)) + elif target_type == 'style': + return redirect(url_for('style_detail', slug=safe_slug)) + elif target_type == 'scene': + return redirect(url_for('scene_detail', slug=safe_slug)) + elif target_type == 'detailer': + return redirect(url_for('detailer_detail', slug=safe_slug)) + + except Exception as e: + print(f"Transfer save error: {e}") + flash(f'Failed to save transferred {target_type}: {e}') + return redirect(url_for('transfer_character', slug=slug)) + + # GET request - show transfer form + return render_template('transfer.html', character=character) + + @app.route('/create', methods=['GET', 'POST']) + def create_character(): + # Form data to preserve on errors + 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' + outfit_mode = request.form.get('outfit_mode', 'generate') # 'generate', 'existing', 'none' + existing_outfit_id = request.form.get('existing_outfit_id') + + # Store form data for re-rendering on error + form_data = { + 'name': name, + 'filename': slug, + 'prompt': prompt, + 'use_llm': use_llm, + 'outfit_mode': outfit_mode, + 'existing_outfit_id': existing_outfit_id + } + + # Check for AJAX request + is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' + + # Auto-generate slug from name if not provided + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + # Validate slug + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'character' + + # Find available filename (increment if exists) + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + # Check if LLM generation is requested + if use_llm: + if not prompt: + error_msg = "Description is required when AI generation is enabled." + if is_ajax: + return jsonify({'error': error_msg}), 400 + flash(error_msg) + return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) + + # Step 1: Generate or select outfit first + default_outfit_id = 'default' + generated_outfit = None + + if outfit_mode == 'generate': + # Generate outfit with LLM + outfit_slug = f"{safe_slug}_outfit" + outfit_name = f"{name} - default" + + outfit_prompt = f"""Generate an outfit for character "{name}". +The character is described as: {prompt} + +Create an outfit JSON with wardrobe fields appropriate for this character.""" + + system_prompt = load_prompt('outfit_system.txt') + if not system_prompt: + error_msg = "Outfit system prompt file not found." + if is_ajax: + return jsonify({'error': error_msg}), 500 + flash(error_msg) + return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) + + try: + outfit_response = call_llm(outfit_prompt, system_prompt) + clean_outfit_json = outfit_response.replace('```json', '').replace('```', '').strip() + outfit_data = json.loads(clean_outfit_json) + + # Enforce outfit IDs + outfit_data['outfit_id'] = outfit_slug + outfit_data['outfit_name'] = outfit_name + + # Ensure required fields + if 'wardrobe' not in outfit_data: + outfit_data['wardrobe'] = { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + } + if 'lora' not in outfit_data: + outfit_data['lora'] = { + "lora_name": "", + "lora_weight": 0.8, + "lora_triggers": "" + } + if 'tags' not in outfit_data: + outfit_data['tags'] = [] + + # Save the outfit + outfit_path = os.path.join(app.config['CLOTHING_DIR'], f"{outfit_slug}.json") + with open(outfit_path, 'w') as f: + json.dump(outfit_data, f, indent=2) + + # Create DB entry + generated_outfit = Outfit( + outfit_id=outfit_slug, + slug=outfit_slug, + name=outfit_name, + data=outfit_data + ) + db.session.add(generated_outfit) + db.session.commit() + + default_outfit_id = outfit_slug + logger.info(f"Generated outfit: {outfit_name} for character {name}") + + except Exception as e: + print(f"Outfit generation error: {e}") + # Fall back to default + default_outfit_id = 'default' + + elif outfit_mode == 'existing' and existing_outfit_id: + # Use selected existing outfit + default_outfit_id = existing_outfit_id + else: + # Use default outfit + default_outfit_id = 'default' + + # Step 2: Generate character (without wardrobe section) + char_prompt = f"""Generate a character named "{name}". +Description: {prompt} + +Default Outfit: {default_outfit_id} + +Create a character JSON with identity, styles, and defaults sections. +Do NOT include a wardrobe section - the outfit is handled separately.""" + + system_prompt = load_prompt('character_system.txt') + if not system_prompt: + error_msg = "Character system prompt file not found." + if is_ajax: + return jsonify({'error': error_msg}), 500 + flash(error_msg) + return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) + + try: + llm_response = call_llm(char_prompt, system_prompt) + + # Clean response (remove markdown if present) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + char_data = json.loads(clean_json) + + # Enforce IDs + char_data['character_id'] = safe_slug + char_data['character_name'] = name + + # Ensure outfit reference is set + if 'defaults' not in char_data: + char_data['defaults'] = {} + char_data['defaults']['outfit'] = default_outfit_id + + # Remove any wardrobe section that LLM might have added + char_data.pop('wardrobe', None) + + except Exception as e: + print(f"LLM error: {e}") + error_msg = f"Failed to generate character profile: {e}" + if is_ajax: + return jsonify({'error': error_msg}), 500 + flash(error_msg) + return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) + else: + # Non-LLM: Create minimal character template + char_data = { + "character_id": safe_slug, + "character_name": name, + "identity": { + "base_specs": prompt, + "hair": "", + "eyes": "", + "hands": "", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "", + "outfit": existing_outfit_id if outfit_mode == 'existing' else 'default' + }, + "styles": { + "aesthetic": "", + "primary_color": "", + "secondary_color": "", + "tertiary_color": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 1, + "lora_weight_min": 0.7, + "lora_weight_max": 1, + "lora_triggers": "" + }, + "tags": [] + } + + try: + # Save file + file_path = os.path.join(app.config['CHARACTERS_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(char_data, f, indent=2) + + # Add to DB + new_char = Character( + character_id=safe_slug, + slug=safe_slug, + filename=f"{safe_slug}.json", + name=name, + data=char_data + ) + + # If outfit was generated, assign it to the character + if outfit_mode == 'generate' and default_outfit_id != 'default': + new_char.assigned_outfit_ids = [default_outfit_id] + db.session.commit() + + db.session.add(new_char) + db.session.commit() + + flash('Character created successfully!') + return redirect(url_for('detail', slug=safe_slug)) + + except Exception as e: + print(f"Save error: {e}") + error_msg = f"Failed to create character: {e}" + if is_ajax: + return jsonify({'error': error_msg}), 500 + flash(error_msg) + return render_template('create.html', form_data=form_data, all_outfits=Outfit.query.order_by(Outfit.name).all()) + + # GET request: show all outfits for the selector + all_outfits = Outfit.query.order_by(Outfit.name).all() + return render_template('create.html', form_data={}, all_outfits=all_outfits) + + @app.route('/character//edit', methods=['GET', 'POST']) + def edit_character(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('characters') + char_looks = Look.query.filter_by(character_id=character.character_id).order_by(Look.name).all() + + if request.method == 'POST': + try: + # 1. Update basic fields + character.name = request.form.get('character_name') + + # 2. Rebuild the data dictionary + new_data = character.data.copy() + new_data['character_name'] = character.name + + # Update nested sections (non-wardrobe) + for section in ['identity', 'defaults', 'styles', 'lora']: + if section in new_data: + for key in new_data[section]: + form_key = f"{section}_{key}" + if form_key in request.form: + val = request.form.get(form_key) + # Handle numeric weight + if key == 'lora_weight': + try: val = float(val) + except: val = 1.0 + new_data[section][key] = val + + # LoRA weight randomization bounds (new fields not present in existing JSON) + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + else: + new_data.setdefault('lora', {}).pop(bound, None) + + # Handle wardrobe - support both nested and flat formats + wardrobe = new_data.get('wardrobe', {}) + if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): + # New nested format - update each outfit + for outfit_name in wardrobe.keys(): + for key in wardrobe[outfit_name].keys(): + form_key = f"wardrobe_{outfit_name}_{key}" + if form_key in request.form: + wardrobe[outfit_name][key] = request.form.get(form_key) + new_data['wardrobe'] = wardrobe + else: + # Legacy flat format + if 'wardrobe' in new_data: + for key in new_data['wardrobe'].keys(): + form_key = f"wardrobe_{key}" + 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] + + character.data = new_data + flag_modified(character, "data") + + # 3. Write back to JSON file + # Use the filename we stored during sync, or fallback to a sanitized ID + char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" + file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Character profile updated successfully!') + return redirect(url_for('detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('edit.html', character=character, loras=loras, char_looks=char_looks) + + @app.route('/character//outfit/switch', methods=['POST']) + def switch_outfit(slug): + """Switch the active outfit for a character.""" + character = Character.query.filter_by(slug=slug).first_or_404() + outfit_id = request.form.get('outfit', 'default') + + # Get available outfits and validate + available_outfits = character.get_available_outfits() + available_ids = [o['outfit_id'] for o in available_outfits] + + if outfit_id in available_ids: + character.active_outfit = outfit_id + db.session.commit() + outfit_name = next((o['name'] for o in available_outfits if o['outfit_id'] == outfit_id), outfit_id) + flash(f'Switched to "{outfit_name}" outfit.') + else: + flash(f'Outfit "{outfit_id}" not found.', 'error') + + return redirect(url_for('detail', slug=slug)) + + @app.route('/character//outfit/assign', methods=['POST']) + def assign_outfit(slug): + """Assign an outfit from the Outfit table to this character.""" + character = Character.query.filter_by(slug=slug).first_or_404() + outfit_id = request.form.get('outfit_id') + + if not outfit_id: + flash('No outfit selected.', 'error') + return redirect(url_for('detail', slug=slug)) + + # Check if outfit exists + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + if not outfit: + flash(f'Outfit "{outfit_id}" not found.', 'error') + return redirect(url_for('detail', slug=slug)) + + # Assign outfit + if character.assign_outfit(outfit_id): + db.session.commit() + flash(f'Assigned outfit "{outfit.name}" to {character.name}.') + else: + flash(f'Outfit "{outfit.name}" is already assigned.', 'warning') + + return redirect(url_for('detail', slug=slug)) + + @app.route('/character//outfit/unassign/', methods=['POST']) + def unassign_outfit(slug, outfit_id): + """Unassign an outfit from this character.""" + character = Character.query.filter_by(slug=slug).first_or_404() + + if character.unassign_outfit(outfit_id): + db.session.commit() + flash('Outfit unassigned.') + else: + flash('Outfit was not assigned.', 'warning') + + return redirect(url_for('detail', slug=slug)) + + @app.route('/character//outfit/add', methods=['POST']) + def add_outfit(slug): + """Add a new outfit to a character.""" + character = Character.query.filter_by(slug=slug).first_or_404() + outfit_name = request.form.get('outfit_name', '').strip() + + if not outfit_name: + flash('Outfit name cannot be empty.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + # Sanitize outfit name for use as key + safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', outfit_name.lower()) + + # Get wardrobe data + wardrobe = character.data.get('wardrobe', {}) + + # Ensure wardrobe is in new nested format + if 'default' not in wardrobe or not isinstance(wardrobe.get('default'), dict): + # Convert legacy format + wardrobe = {'default': wardrobe} + + # Check if outfit already exists + if safe_name in wardrobe: + flash(f'Outfit "{safe_name}" already exists.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + # Create new outfit (copy from default as template) + default_outfit = wardrobe.get('default', { + 'headwear': '', 'top': '', 'legwear': '', + 'footwear': '', 'hands': '', 'accessories': '' + }) + wardrobe[safe_name] = default_outfit.copy() + + # Update character data + character.data['wardrobe'] = wardrobe + flag_modified(character, 'data') + + # Save to JSON file + char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" + file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) + with open(file_path, 'w') as f: + json.dump(character.data, f, indent=2) + + db.session.commit() + flash(f'Added new outfit "{safe_name}".') + + return redirect(url_for('edit_character', slug=slug)) + + @app.route('/character//outfit/delete', methods=['POST']) + def delete_outfit(slug): + """Delete an outfit from a character.""" + character = Character.query.filter_by(slug=slug).first_or_404() + outfit_name = request.form.get('outfit', '') + + wardrobe = character.data.get('wardrobe', {}) + + # Cannot delete default + if outfit_name == 'default': + flash('Cannot delete the default outfit.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + if outfit_name not in wardrobe: + flash(f'Outfit "{outfit_name}" not found.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + # Delete outfit + del wardrobe[outfit_name] + character.data['wardrobe'] = wardrobe + flag_modified(character, 'data') + + # Switch active outfit if deleted was active + if character.active_outfit == outfit_name: + character.active_outfit = 'default' + + # Save to JSON file + char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" + file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) + with open(file_path, 'w') as f: + json.dump(character.data, f, indent=2) + + db.session.commit() + flash(f'Deleted outfit "{outfit_name}".') + + return redirect(url_for('edit_character', slug=slug)) + + @app.route('/character//outfit/rename', methods=['POST']) + def rename_outfit(slug): + """Rename an outfit.""" + character = Character.query.filter_by(slug=slug).first_or_404() + old_name = request.form.get('old_name', '') + new_name = request.form.get('new_name', '').strip() + + if not new_name: + flash('New name cannot be empty.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + # Sanitize new name + safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', new_name.lower()) + + wardrobe = character.data.get('wardrobe', {}) + + if old_name not in wardrobe: + flash(f'Outfit "{old_name}" not found.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + if safe_name in wardrobe and safe_name != old_name: + flash(f'Outfit "{safe_name}" already exists.', 'error') + return redirect(url_for('edit_character', slug=slug)) + + # Rename (copy to new key, delete old) + wardrobe[safe_name] = wardrobe.pop(old_name) + character.data['wardrobe'] = wardrobe + flag_modified(character, 'data') + + # Update active outfit if renamed was active + if character.active_outfit == old_name: + character.active_outfit = safe_name + + # Save to JSON file + char_file = character.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', character.character_id)}.json" + file_path = os.path.join(app.config['CHARACTERS_DIR'], char_file) + with open(file_path, 'w') as f: + json.dump(character.data, f, indent=2) + + db.session.commit() + flash(f'Renamed outfit "{old_name}" to "{safe_name}".') + + return redirect(url_for('edit_character', slug=slug)) + + @app.route('/character//upload', methods=['POST']) + def upload_image(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create character subfolder + char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}") + os.makedirs(char_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(char_folder, filename) + file.save(file_path) + + # Store relative path in DB + character.image_path = f"characters/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('detail', slug=slug)) + + @app.route('/character//replace_cover_from_preview', methods=['POST']) + def replace_cover_from_preview(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + character.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('detail', slug=slug)) + + @app.route('/get_missing_characters') + def get_missing_characters(): + missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all() + return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} + + @app.route('/clear_all_covers', methods=['POST']) + def clear_all_covers(): + characters = Character.query.all() + for char in characters: + char.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/generate_missing', methods=['POST']) + def generate_missing(): + missing = Character.query.filter( + (Character.image_path == None) | (Character.image_path == '') + ).order_by(Character.name).all() + + if not missing: + flash("No characters missing cover images.") + return redirect(url_for('index')) + + enqueued = 0 + for character in missing: + try: + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + prompts = build_prompt(character.data, None, character.default_fields, character.active_outfit) + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) + + _slug = character.slug + _enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character)) + enqueued += 1 + except Exception as e: + print(f"Error queuing cover generation for {character.name}: {e}") + + flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.") + return redirect(url_for('index')) + + @app.route('/character//generate', methods=['POST']) + def generate_image(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'prefs_{slug}'] = selected_fields + session[f'extra_pos_{slug}'] = extra_positive + session[f'extra_neg_{slug}'] = extra_negative + session.modified = True + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Build workflow + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + prompts = build_prompt(character.data, selected_fields, character.default_fields, character.active_outfit) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + + label = f"{character.name} – {action}" + job = _enqueue_job(label, workflow, _make_finalize('characters', slug, Character, action)) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + return redirect(url_for('detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('detail', slug=slug)) + + @app.route('/character//save_defaults', methods=['POST']) + def save_defaults(slug): + character = Character.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + character.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this character!') + return redirect(url_for('detail', slug=slug)) diff --git a/routes/checkpoints.py b/routes/checkpoints.py new file mode 100644 index 0000000..03aa817 --- /dev/null +++ b/routes/checkpoints.py @@ -0,0 +1,286 @@ +import json +import os +import re +import time +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +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.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 +from services.llm import load_prompt, call_llm +from utils import allowed_file + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None): + """Build and return a prepared ComfyUI workflow dict for a checkpoint generation.""" + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + if character: + combined_data = character.data.copy() + combined_data['character_id'] = character.character_id + selected_fields = [] + for key in ['base_specs', 'hair', 'eyes']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + selected_fields.append('special::name') + wardrobe = character.get_active_wardrobe() + for key in ['full_body', 'top', 'bottom']: + if wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit) + _append_background(prompts, character) + else: + prompts = { + "main": "masterpiece, best quality, 1girl, solo, simple background, looking at viewer", + "face": "masterpiece, best quality", + "hand": "masterpiece, best quality", + } + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_obj.checkpoint_path, + checkpoint_data=ckpt_obj.data or {}, custom_negative=extra_negative or None, fixed_seed=fixed_seed) + return workflow + + @app.route('/checkpoints') + def checkpoints_index(): + checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() + return render_template('checkpoints/index.html', checkpoints=checkpoints) + + @app.route('/checkpoints/rescan', methods=['POST']) + def rescan_checkpoints(): + sync_checkpoints() + flash('Checkpoint list synced from disk.') + return redirect(url_for('checkpoints_index')) + + @app.route('/checkpoint/') + def checkpoint_detail(slug): + ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + preview_image = session.get(f'preview_checkpoint_{slug}') + selected_character = session.get(f'char_checkpoint_{slug}') + extra_positive = session.get(f'extra_pos_checkpoint_{slug}', '') + extra_negative = session.get(f'extra_neg_checkpoint_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"checkpoints/{slug}/{f}" for f in files] + + return render_template('checkpoints/detail.html', ckpt=ckpt, characters=characters, + preview_image=preview_image, selected_character=selected_character, + existing_previews=existing_previews, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/checkpoint//upload', methods=['POST']) + def upload_checkpoint_image(slug): + ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() + if 'image' not in request.files: + flash('No file part') + return redirect(url_for('checkpoint_detail', slug=slug)) + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(url_for('checkpoint_detail', slug=slug)) + if file and allowed_file(file.filename): + folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") + os.makedirs(folder, exist_ok=True) + filename = secure_filename(file.filename) + file.save(os.path.join(folder, filename)) + ckpt.image_path = f"checkpoints/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + return redirect(url_for('checkpoint_detail', slug=slug)) + + @app.route('/checkpoint//generate', methods=['POST']) + def generate_checkpoint_image(slug): + ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() + try: + character_slug = request.form.get('character_slug', '') + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + session[f'char_checkpoint_{slug}'] = character_slug + session[f'extra_pos_checkpoint_{slug}'] = extra_positive + session[f'extra_neg_checkpoint_{slug}'] = extra_negative + session.modified = True + + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + workflow = _build_checkpoint_workflow(ckpt, character, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative) + + char_label = character.name if character else 'random' + label = f"Checkpoint: {ckpt.name} ({char_label})" + job = _enqueue_job(label, workflow, _make_finalize('checkpoints', slug, Checkpoint)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + return redirect(url_for('checkpoint_detail', slug=slug)) + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('checkpoint_detail', slug=slug)) + + @app.route('/checkpoint//replace_cover_from_preview', methods=['POST']) + def replace_checkpoint_cover_from_preview(slug): + ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + ckpt.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('checkpoint_detail', slug=slug)) + + @app.route('/checkpoint//save_json', methods=['POST']) + def save_checkpoint_json(slug): + ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + ckpt.data = new_data + flag_modified(ckpt, 'data') + db.session.commit() + checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') + file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json') + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} + + @app.route('/get_missing_checkpoints') + def get_missing_checkpoints(): + missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all() + return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} + + @app.route('/clear_all_checkpoint_covers', methods=['POST']) + def clear_all_checkpoint_covers(): + for ckpt in Checkpoint.query.all(): + ckpt.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/checkpoints/bulk_create', methods=['POST']) + def bulk_create_checkpoints(): + checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') + os.makedirs(checkpoints_dir, exist_ok=True) + + overwrite = request.form.get('overwrite') == 'true' + created_count = 0 + skipped_count = 0 + overwritten_count = 0 + + system_prompt = load_prompt('checkpoint_system.txt') + if not system_prompt: + flash('Checkpoint system prompt file not found.', 'error') + return redirect(url_for('checkpoints_index')) + + dirs = [ + (app.config.get('ILLUSTRIOUS_MODELS_DIR', ''), 'Illustrious'), + (app.config.get('NOOB_MODELS_DIR', ''), 'Noob'), + ] + + for dirpath, family in dirs: + if not dirpath or not os.path.exists(dirpath): + continue + + for filename in sorted(os.listdir(dirpath)): + if not (filename.endswith('.safetensors') or filename.endswith('.ckpt')): + continue + + checkpoint_path = f"{family}/{filename}" + name_base = filename.rsplit('.', 1)[0] + safe_id = re.sub(r'[^a-zA-Z0-9_]', '_', checkpoint_path.rsplit('.', 1)[0]).lower().strip('_') + json_filename = f"{safe_id}.json" + json_path = os.path.join(checkpoints_dir, json_filename) + + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped_count += 1 + continue + + # Look for a matching HTML file alongside the model file + html_path = os.path.join(dirpath, f"{name_base}.html") + html_content = "" + if os.path.exists(html_path): + try: + with open(html_path, 'r', encoding='utf-8', errors='ignore') as hf: + html_raw = hf.read() + 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 for {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 + else: + ckpt_data = defaults + + 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}") + + 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.') + + return redirect(url_for('checkpoints_index')) diff --git a/routes/detailers.py b/routes/detailers.py new file mode 100644 index 0000000..05905fb --- /dev/null +++ b/routes/detailers.py @@ -0,0 +1,457 @@ +import json +import os +import re +import time +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +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.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 +from services.llm import load_prompt, call_llm +from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None, fixed_seed=None): + if character: + combined_data = character.data.copy() + combined_data['character_id'] = character.character_id + + # Merge detailer prompt into character's tags + 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', {}) + if detailer_lora.get('lora_triggers'): + if 'lora' not in combined_data: combined_data['lora'] = {} + combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {detailer_lora['lora_triggers']}" + + # Merge character identity and wardrobe fields into selected_fields + if selected_fields: + _ensure_character_fields(character, selected_fields) + else: + # Auto-include essential character fields (minimal set for batch/default generation) + selected_fields = [] + for key in ['base_specs', 'hair', 'eyes']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + selected_fields.append('special::name') + wardrobe = character.get_active_wardrobe() + for key in _WARDROBE_KEYS: + if wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + selected_fields.extend(['special::tags', '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'] + default_fields = detailer_obj.default_fields + active_outfit = 'default' + + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) + + _append_background(prompts, character) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, detailer=detailer_obj, action=action, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + return workflow + + @app.route('/detailers') + def detailers_index(): + detailers = Detailer.query.order_by(Detailer.name).all() + return render_template('detailers/index.html', detailers=detailers) + + @app.route('/detailers/rescan', methods=['POST']) + def rescan_detailers(): + sync_detailers() + flash('Database synced with detailer files.') + return redirect(url_for('detailers_index')) + + @app.route('/detailer/') + def detailer_detail(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + actions = Action.query.order_by(Action.name).all() + + # Load state from session + preferences = session.get(f'prefs_detailer_{slug}') + preview_image = session.get(f'preview_detailer_{slug}') + selected_character = session.get(f'char_detailer_{slug}') + selected_action = session.get(f'action_detailer_{slug}') + extra_positive = session.get(f'extra_pos_detailer_{slug}', '') + extra_negative = session.get(f'extra_neg_detailer_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"detailers/{slug}/{f}" for f in files] + + return render_template('detailers/detail.html', detailer=detailer, characters=characters, + actions=actions, preferences=preferences, preview_image=preview_image, + selected_character=selected_character, selected_action=selected_action, + extra_positive=extra_positive, extra_negative=extra_negative, + existing_previews=existing_previews) + + @app.route('/detailer//edit', methods=['GET', 'POST']) + def edit_detailer(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('detailers') + + if request.method == 'POST': + try: + # 1. Update basic fields + detailer.name = request.form.get('detailer_name') + + # 2. Rebuild the data dictionary + new_data = detailer.data.copy() + new_data['detailer_name'] = detailer.name + + # Update prompt (stored as a plain string) + new_data['prompt'] = request.form.get('detailer_prompt', '') + + # Update lora section + if 'lora' in new_data: + for key in new_data['lora'].keys(): + form_key = f"lora_{key}" + if form_key in request.form: + val = request.form.get(form_key) + if key == 'lora_weight': + try: val = float(val) + except: val = 1.0 + new_data['lora'][key] = val + + # LoRA weight randomization bounds + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + 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()] + + detailer.data = new_data + flag_modified(detailer, "data") + + # 3. Write back to JSON file + detailer_file = detailer.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', detailer.detailer_id)}.json" + file_path = os.path.join(app.config['DETAILERS_DIR'], detailer_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Detailer updated successfully!') + return redirect(url_for('detailer_detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('detailers/edit.html', detailer=detailer, loras=loras) + + @app.route('/detailer//upload', methods=['POST']) + def upload_detailer_image(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create detailer subfolder + detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") + os.makedirs(detailer_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(detailer_folder, filename) + file.save(file_path) + + # Store relative path in DB + detailer.image_path = f"detailers/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('detailer_detail', slug=slug)) + + @app.route('/detailer//generate', methods=['POST']) + def generate_detailer_image(slug): + detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get selected character (if any) + character_slug = request.form.get('character_slug', '') + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get selected action (if any) + action_slug = request.form.get('action_slug', '') + action_obj = Action.query.filter_by(slug=action_slug).first() if action_slug else None + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'char_detailer_{slug}'] = character_slug + session[f'action_detailer_{slug}'] = action_slug + session[f'extra_pos_detailer_{slug}'] = extra_positive + session[f'extra_neg_detailer_{slug}'] = extra_negative + session[f'prefs_detailer_{slug}'] = selected_fields + session.modified = True + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Build workflow using helper + workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative, fixed_seed=fixed_seed) + + char_label = character.name if character else 'no character' + label = f"Detailer: {detailer_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + return redirect(url_for('detailer_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('detailer_detail', slug=slug)) + + @app.route('/detailer//save_defaults', methods=['POST']) + def save_detailer_defaults(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + detailer.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this detailer!') + return redirect(url_for('detailer_detail', slug=slug)) + + @app.route('/detailer//replace_cover_from_preview', methods=['POST']) + def replace_detailer_cover_from_preview(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + detailer.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('detailer_detail', slug=slug)) + + @app.route('/detailer//save_json', methods=['POST']) + def save_detailer_json(slug): + detailer = Detailer.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + detailer.data = new_data + flag_modified(detailer, 'data') + db.session.commit() + if detailer.filename: + file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} + + @app.route('/detailers/bulk_create', methods=['POST']) + def bulk_create_detailers_from_loras(): + _s = Settings.query.first() + detailers_lora_dir = ((_s.lora_dir_detailers if _s else None) or '/ImageModels/lora/Illustrious/Detailers').rstrip('/') + _lora_subfolder = os.path.basename(detailers_lora_dir) + if not os.path.exists(detailers_lora_dir): + flash('Detailers LoRA directory not found.', 'error') + return redirect(url_for('detailers_index')) + + overwrite = request.form.get('overwrite') == 'true' + created_count = 0 + skipped_count = 0 + overwritten_count = 0 + + 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')) + + for filename in os.listdir(detailers_lora_dir): + if filename.endswith('.safetensors'): + name_base = filename.rsplit('.', 1)[0] + detailer_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) + 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) + + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped_count += 1 + continue + + html_filename = f"{name_base}.html" + html_path = os.path.join(detailers_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}") + + 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###" + + 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 + + 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 + + with open(json_path, 'w') as f: + json.dump(detailer_data, f, indent=2) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + # 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}") + + 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.') + + return redirect(url_for('detailers_index')) + + @app.route('/detailer/create', methods=['GET', 'POST']) + def create_detailer(): + if request.method == 'POST': + name = request.form.get('name') + slug = request.form.get('filename', '').strip() + + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'detailer' + + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + detailer_data = { + "detailer_id": safe_slug, + "detailer_name": name, + "prompt": "", + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_triggers": "" + } + } + + try: + file_path = os.path.join(app.config['DETAILERS_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(detailer_data, f, indent=2) + + new_detailer = Detailer( + detailer_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", + name=name, data=detailer_data + ) + db.session.add(new_detailer) + db.session.commit() + + flash('Detailer created successfully!') + return redirect(url_for('detailer_detail', slug=safe_slug)) + 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') diff --git a/routes/gallery.py b/routes/gallery.py new file mode 100644 index 0000000..1b05582 --- /dev/null +++ b/routes/gallery.py @@ -0,0 +1,332 @@ +import json +import os +import logging + +from flask import render_template, request, current_app +from models import ( + db, Character, Outfit, Action, Style, Scene, Detailer, Look, Checkpoint, +) + +logger = logging.getLogger('gaze') + + +GALLERY_CATEGORIES = ['characters', 'actions', 'outfits', 'scenes', 'styles', 'detailers', 'checkpoints'] + +_MODEL_MAP = { + 'characters': Character, + 'actions': Action, + 'outfits': Outfit, + 'scenes': Scene, + 'styles': Style, + 'detailers': Detailer, + 'checkpoints': Checkpoint, +} + + +def register_routes(app): + + 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'] + images = [] + cats = GALLERY_CATEGORIES if category_filter == 'all' else [category_filter] + + for cat in 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: + if slug_filter and slug_filter != item_slug: + continue + 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(('.png', '.jpg', '.jpeg', '.webp')): + continue + try: + ts = int(filename.replace('gen_', '').rsplit('.', 1)[0]) + except ValueError: + ts = 0 + images.append({ + 'path': f"{cat}/{item_slug}/{filename}", + 'category': cat, + 'slug': item_slug, + 'filename': filename, + 'timestamp': ts, + }) + + images.sort(key=lambda x: x['timestamp'], reverse=True) + return images + + def _enrich_with_names(images): + """Add item_name field to each image dict, querying DB once per category.""" + by_cat = {} + for img in images: + by_cat.setdefault(img['category'], set()).add(img['slug']) + + name_map = {} + for cat, slugs in by_cat.items(): + Model = _MODEL_MAP.get(cat) + if not Model: + continue + items = Model.query.filter(Model.slug.in_(slugs)).with_entities(Model.slug, Model.name).all() + for slug, name in items: + name_map[(cat, slug)] = name + + for img in images: + img['item_name'] = name_map.get((img['category'], img['slug']), img['slug']) + return images + + def _parse_comfy_png_metadata(image_path): + """Read ComfyUI generation metadata from a PNG's tEXt 'prompt' chunk. + + Returns a dict with keys: positive, negative, checkpoint, loras, + seed, steps, cfg, sampler, scheduler. Any missing field is None/[]. + """ + from PIL import Image as PilImage + + result = { + 'positive': None, + 'negative': None, + 'checkpoint': None, + 'loras': [], # list of {name, strength} + 'seed': None, + 'steps': None, + 'cfg': None, + 'sampler': None, + 'scheduler': None, + } + + try: + with PilImage.open(image_path) as im: + raw = im.info.get('prompt') + if not raw: + return result + nodes = json.loads(raw) + except Exception: + return result + + for node in nodes.values(): + ct = node.get('class_type', '') + inp = node.get('inputs', {}) + + if ct == 'KSampler': + result['seed'] = inp.get('seed') + result['steps'] = inp.get('steps') + result['cfg'] = inp.get('cfg') + result['sampler'] = inp.get('sampler_name') + result['scheduler'] = inp.get('scheduler') + + elif ct == 'CheckpointLoaderSimple': + result['checkpoint'] = inp.get('ckpt_name') + + elif ct == 'CLIPTextEncode': + # Identify positive vs negative by which KSampler input they connect to. + # Simpler heuristic: node "6" = positive, node "7" = negative (our fixed workflow). + # But to be robust, we check both via node graph references where possible. + # Fallback: first CLIPTextEncode = positive, second = negative. + text = inp.get('text', '') + if result['positive'] is None: + result['positive'] = text + elif result['negative'] is None: + result['negative'] = text + + elif ct == 'LoraLoader': + name = inp.get('lora_name', '') + if name: + result['loras'].append({ + 'name': name, + 'strength': inp.get('strength_model', 1.0), + }) + + # Re-parse with fixed node IDs from the known workflow (more reliable) + try: + if '6' in nodes: + result['positive'] = nodes['6']['inputs'].get('text', result['positive']) + if '7' in nodes: + result['negative'] = nodes['7']['inputs'].get('text', result['negative']) + except Exception: + pass + + return result + + @app.route('/gallery') + def gallery(): + category = request.args.get('category', 'all') + slug = request.args.get('slug', '') + sort = request.args.get('sort', 'newest') + 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) + + if sort == 'oldest': + images.reverse() + elif sort == 'random': + import random + random.shuffle(images) + + total = len(images) + total_pages = max(1, (total + per_page - 1) // per_page) + page = min(page, total_pages) + page_images = images[(page - 1) * per_page: page * per_page] + _enrich_with_names(page_images) + + # Enrich with metadata for Info view + upload_folder = os.path.abspath(app.config['UPLOAD_FOLDER']) + for img in page_images: + abs_img = os.path.join(upload_folder, img['path']) + if os.path.isfile(abs_img) and abs_img.lower().endswith('.png'): + img['meta'] = _parse_comfy_png_metadata(abs_img) + else: + img['meta'] = {} + + slug_options = [] + if category != 'all': + Model = _MODEL_MAP.get(category) + if Model: + slug_options = [(r.slug, r.name) for r in Model.query.order_by(Model.name).with_entities(Model.slug, Model.name).all()] + + return render_template( + 'gallery.html', + images=page_images, + page=page, + per_page=per_page, + total=total, + total_pages=total_pages, + category=category, + slug=slug, + sort=sort, + categories=GALLERY_CATEGORIES, + slug_options=slug_options, + ) + + @app.route('/gallery/prompt-data') + def gallery_prompt_data(): + """Return generation metadata for a specific image by reading its PNG tEXt chunk.""" + img_path = request.args.get('path', '') + if not img_path: + return {'error': 'path parameter required'}, 400 + + # Validate path stays within uploads folder + 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): + return {'error': 'Invalid path'}, 400 + if not os.path.isfile(abs_img): + return {'error': 'File not found'}, 404 + + meta = _parse_comfy_png_metadata(abs_img) + meta['path'] = img_path + return meta + + @app.route('/gallery/delete', methods=['POST']) + def gallery_delete(): + """Delete a generated image from the gallery. Only the image file is removed.""" + data = request.get_json(silent=True) or {} + img_path = data.get('path', '') + + if not img_path: + return {'error': 'path required'}, 400 + + if len(img_path.split('/')) != 3: + return {'error': 'invalid path format'}, 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): + return {'error': 'Invalid path'}, 400 + + if os.path.isfile(abs_img): + os.remove(abs_img) + + return {'status': 'ok'} + + @app.route('/resource///delete', methods=['POST']) + def resource_delete(category, slug): + """Delete a resource item from a category gallery. + + soft: removes JSON data file + DB record; LoRA/checkpoint file kept on disk. + hard: removes JSON data file + LoRA/checkpoint safetensors + DB record. + """ + _RESOURCE_MODEL_MAP = { + 'looks': Look, + 'styles': Style, + 'actions': Action, + 'outfits': Outfit, + 'scenes': Scene, + 'detailers': Detailer, + 'checkpoints': Checkpoint, + } + _RESOURCE_DATA_DIRS = { + 'looks': app.config['LOOKS_DIR'], + 'styles': app.config['STYLES_DIR'], + 'actions': app.config['ACTIONS_DIR'], + 'outfits': app.config['CLOTHING_DIR'], + 'scenes': app.config['SCENES_DIR'], + 'detailers': app.config['DETAILERS_DIR'], + 'checkpoints': app.config['CHECKPOINTS_DIR'], + } + _LORA_BASE = '/ImageModels/lora/' + + if category not in _RESOURCE_MODEL_MAP: + return {'error': 'unknown category'}, 400 + + req = request.get_json(silent=True) or {} + mode = req.get('mode', 'soft') + + data_dir = _RESOURCE_DATA_DIRS[category] + json_path = os.path.join(data_dir, f'{slug}.json') + + deleted = [] + asset_abs = None + + # Resolve asset path before deleting JSON (hard only) + if mode == 'hard' and os.path.isfile(json_path): + try: + with open(json_path) as f: + item_data = json.load(f) + if category == 'checkpoints': + ckpt_rel = item_data.get('checkpoint_path', '') + if ckpt_rel.startswith('Illustrious/'): + asset_abs = os.path.join(app.config['ILLUSTRIOUS_MODELS_DIR'], + ckpt_rel[len('Illustrious/'):]) + elif ckpt_rel.startswith('Noob/'): + asset_abs = os.path.join(app.config['NOOB_MODELS_DIR'], + ckpt_rel[len('Noob/'):]) + else: + lora_name = item_data.get('lora', {}).get('lora_name', '') + if lora_name: + asset_abs = os.path.join(_LORA_BASE, lora_name) + except Exception: + pass + + # Delete JSON + if os.path.isfile(json_path): + os.remove(json_path) + deleted.append('json') + + # Delete LoRA/checkpoint file (hard only) + if mode == 'hard' and asset_abs and os.path.isfile(asset_abs): + os.remove(asset_abs) + deleted.append('lora' if category != 'checkpoints' else 'checkpoint') + + # Remove DB record + Model = _RESOURCE_MODEL_MAP[category] + rec = Model.query.filter_by(slug=slug).first() + if rec: + db.session.delete(rec) + db.session.commit() + deleted.append('db') + + return {'status': 'ok', 'deleted': deleted} diff --git a/routes/generator.py b/routes/generator.py new file mode 100644 index 0000000..37072d4 --- /dev/null +++ b/routes/generator.py @@ -0,0 +1,139 @@ +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 services.file_io import get_available_checkpoints + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/generator', methods=['GET', 'POST']) + def generator(): + characters = Character.query.order_by(Character.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"] + + 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', '') + + 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 + + character = Character.query.filter_by(slug=char_slug).first_or_404() + + 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 [] + + try: + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + # Build base prompts from character defaults + prompts = build_prompt(character.data, default_fields=character.default_fields) + + if override_prompt: + prompts["main"] = override_prompt + else: + extras = build_extras_prompt(sel_actions, sel_outfits, sel_scenes, sel_styles, sel_detailers) + combined = prompts["main"] + if extras: + combined = f"{combined}, {extras}" + if custom_positive: + combined = f"{combined}, {custom_positive}" + prompts["main"] = combined + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # 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, + ) + + print(f"Queueing generator prompt for {character.character_id}") + + _finalize = _make_finalize('characters', character.slug) + label = f"Generator: {character.name}" + job = _enqueue_job(label, workflow, _finalize) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + 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)}") + + return render_template('generator.html', characters=characters, checkpoints=checkpoints, + actions=actions, outfits=outfits, scenes=scenes, + styles=styles, detailers=detailers) + + @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 + + character = Character.query.filter_by(slug=char_slug).first() + if not character: + return {'error': 'Character not found'}, 404 + + 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', '') + + 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"{combined}, {custom_positive}" + + return {'prompt': combined} diff --git a/routes/looks.py b/routes/looks.py new file mode 100644 index 0000000..e41fe8b --- /dev/null +++ b/routes/looks.py @@ -0,0 +1,592 @@ +import json +import os +import re +import time +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +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.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 +from services.llm import load_prompt, call_llm +from utils import allowed_file + +logger = logging.getLogger('gaze') + + +def _ensure_look_lora_prefix(lora_name): + """Ensure look LoRA paths have the correct 'Illustrious/Looks/' prefix.""" + if not lora_name: + return lora_name + + if not lora_name.startswith('Illustrious/Looks/'): + # Add the prefix if missing + if lora_name.startswith('Illustrious/'): + # Has Illustrious but wrong subfolder - replace + parts = lora_name.split('/', 1) + if len(parts) > 1: + lora_name = 'Illustrious/Looks/' + parts[1] + else: + lora_name = 'Illustrious/Looks/' + lora_name + else: + # No prefix at all - add it + lora_name = 'Illustrious/Looks/' + lora_name + + return lora_name + + +def _fix_look_lora_data(lora_data): + """Fix look LoRA data to ensure correct prefix.""" + if not lora_data: + return lora_data + + lora_name = lora_data.get('lora_name', '') + if lora_name: + lora_data = lora_data.copy() # Avoid mutating original + lora_data['lora_name'] = _ensure_look_lora_prefix(lora_name) + + return lora_data + + +def register_routes(app): + + @app.route('/looks') + def looks_index(): + looks = Look.query.order_by(Look.name).all() + look_assignments = _count_look_assignments() + return render_template('looks/index.html', looks=looks, look_assignments=look_assignments) + + @app.route('/looks/rescan', methods=['POST']) + def rescan_looks(): + sync_looks() + flash('Database synced with look files.') + return redirect(url_for('looks_index')) + + @app.route('/look/') + def look_detail(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Pre-select the linked characters if set (supports multi-character assignment) + preferences = session.get(f'prefs_look_{slug}') + preview_image = session.get(f'preview_look_{slug}') + + # Get linked character IDs (new character_ids JSON field) + linked_character_ids = look.character_ids or [] + # Fallback to legacy character_id if character_ids is empty + if not linked_character_ids and look.character_id: + linked_character_ids = [look.character_id] + + # Session-selected character for preview (single selection for generation) + selected_character = session.get(f'char_look_{slug}', linked_character_ids[0] if linked_character_ids else '') + + # FIX: Add existing_previews scanning (matching other resource routes) + upload_folder = app.config['UPLOAD_FOLDER'] + preview_dir = os.path.join(upload_folder, 'looks', slug) + existing_previews = [] + if os.path.isdir(preview_dir): + for f in os.listdir(preview_dir): + if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): + existing_previews.append(f'looks/{slug}/{f}') + existing_previews.sort() + + extra_positive = session.get(f'extra_pos_look_{slug}', '') + extra_negative = session.get(f'extra_neg_look_{slug}', '') + + return render_template('looks/detail.html', look=look, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, + linked_character_ids=linked_character_ids, + existing_previews=existing_previews, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/look//edit', methods=['GET', 'POST']) + def edit_look(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + loras = get_available_loras('characters') + + if request.method == 'POST': + look.name = request.form.get('look_name', look.name) + + # Handle multiple character IDs from checkboxes + character_ids = request.form.getlist('character_ids') + look.character_ids = character_ids if character_ids else [] + + # Also update legacy character_id field for backward compatibility + if character_ids: + look.character_id = character_ids[0] + else: + look.character_id = None + + new_data = look.data.copy() + new_data['look_name'] = look.name + new_data['character_id'] = look.character_id + + new_data['positive'] = request.form.get('positive', '') + new_data['negative'] = request.form.get('negative', '') + + 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', '') + new_data['lora'] = {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers} + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data['lora'][bound] = float(val_str) + except ValueError: + pass + + tags_raw = request.form.get('tags', '') + new_data['tags'] = [t.strip() for t in tags_raw.split(',') if t.strip()] + + look.data = new_data + flag_modified(look, 'data') + db.session.commit() + + if look.filename: + file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + flash(f'Look "{look.name}" updated!') + return redirect(url_for('look_detail', slug=look.slug)) + + return render_template('looks/edit.html', look=look, characters=characters, loras=loras) + + @app.route('/look//upload', methods=['POST']) + def upload_look_image(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + if 'image' not in request.files: + flash('No file selected') + return redirect(url_for('look_detail', slug=slug)) + file = request.files['image'] + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}') + os.makedirs(look_folder, exist_ok=True) + file_path = os.path.join(look_folder, filename) + file.save(file_path) + look.image_path = f'looks/{slug}/{filename}' + db.session.commit() + return redirect(url_for('look_detail', slug=slug)) + + @app.route('/look//generate', methods=['POST']) + def generate_look_image(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + + try: + action = request.form.get('action', 'preview') + selected_fields = request.form.getlist('include_field') + + character_slug = request.form.get('character_slug', '') + character = None + + # Only load a character when the user explicitly selects one + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + elif character_slug and not character: + # fallback: try matching by character_id + character = Character.query.filter_by(character_id=character_slug).first() + # No fallback to look.character_id — looks are self-contained + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + session[f'prefs_look_{slug}'] = selected_fields + session[f'char_look_{slug}'] = character_slug + session[f'extra_pos_look_{slug}'] = extra_positive + session[f'extra_neg_look_{slug}'] = extra_negative + session.modified = True + + lora_triggers = look.data.get('lora', {}).get('lora_triggers', '') + look_positive = look.data.get('positive', '') + + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + if character: + # Merge character identity with look LoRA and positive prompt + combined_data = { + 'character_id': character.character_id, + 'identity': character.data.get('identity', {}), + 'defaults': character.data.get('defaults', {}), + 'wardrobe': character.data.get('wardrobe', {}).get(character.active_outfit or 'default', + character.data.get('wardrobe', {}).get('default', {})), + 'styles': character.data.get('styles', {}), + 'lora': _fix_look_lora_data(look.data.get('lora', {})), + 'tags': look.data.get('tags', []) + } + _ensure_character_fields(character, selected_fields, + include_wardrobe=False, include_defaults=True) + prompts = build_prompt(combined_data, selected_fields, character.default_fields) + # Append look-specific triggers and positive + extra = ', '.join(filter(None, [lora_triggers, look_positive])) + if extra: + prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra}" if prompts['main'] else extra) + primary_color = character.data.get('styles', {}).get('primary_color', '') + bg = f"{primary_color} simple background" if primary_color else "simple background" + else: + # Look is self-contained: build prompt from its own positive and triggers only + main = _dedup_tags(', '.join(filter(None, ['(solo:1.2)', lora_triggers, look_positive]))) + prompts = {'main': main, 'face': '', 'hand': ''} + bg = "simple background" + + prompts['main'] = _dedup_tags(f"{prompts['main']}, {bg}" if prompts['main'] else bg) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, custom_negative=extra_negative or None, checkpoint=ckpt_path, + checkpoint_data=ckpt_data, look=look, fixed_seed=fixed_seed) + + char_label = character.name if character else 'no character' + label = f"Look: {look.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('looks', slug, Look, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + return redirect(url_for('look_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('look_detail', slug=slug)) + + @app.route('/look//replace_cover_from_preview', methods=['POST']) + def replace_look_cover_from_preview(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + look.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('look_detail', slug=slug)) + + @app.route('/look//save_defaults', methods=['POST']) + def save_look_defaults(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + look.default_fields = request.form.getlist('include_field') + db.session.commit() + flash('Default prompt selection saved!') + return redirect(url_for('look_detail', slug=slug)) + + @app.route('/look//generate_character', methods=['POST']) + def generate_character_from_look(slug): + """Generate a character JSON using a look as the base.""" + look = Look.query.filter_by(slug=slug).first_or_404() + + # Get or validate inputs + character_name = request.form.get('character_name', look.name) + use_llm = request.form.get('use_llm') == 'on' + + # Auto-generate slug + character_slug = re.sub(r'[^a-zA-Z0-9]+', '_', character_name.lower()).strip('_') + character_slug = re.sub(r'[^a-zA-Z0-9_]', '', character_slug) + + # Find available filename + base_slug = character_slug + counter = 1 + while os.path.exists(os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json")): + character_slug = f"{base_slug}_{counter}" + counter += 1 + + if use_llm: + # Use LLM to generate character from look context + system_prompt = load_prompt('character_system.txt') + if not system_prompt: + flash('Character system prompt file not found.', 'error') + return redirect(url_for('look_detail', slug=slug)) + + prompt = f"""Generate a character based on this look description: + +Look Name: {look.name} +Positive Prompt: {look.data.get('positive', '')} +Negative Prompt: {look.data.get('negative', '')} +Tags: {', '.join(look.data.get('tags', []))} +LoRA Triggers: {look.data.get('lora', {}).get('lora_triggers', '')} + +Create a complete character JSON with identity, styles, and appropriate wardrobe fields. +The character should match the visual style described in the look. + +Character Name: {character_name} +Character ID: {character_slug}""" + + try: + llm_response = call_llm(prompt, system_prompt) + # Clean response (remove markdown if present) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + character_data = json.loads(clean_json) + + # Enforce IDs + character_data['character_id'] = character_slug + character_data['character_name'] = character_name + + # Ensure the character inherits the look's LoRA with correct path + lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy()) + character_data['lora'] = lora_data + + except Exception as e: + logger.exception(f"LLM character generation error: {e}") + flash(f'Failed to generate character with AI: {e}', 'error') + return redirect(url_for('look_detail', slug=slug)) + else: + # Create minimal character template + lora_data = _fix_look_lora_data(look.data.get('lora', {}).copy()) + + character_data = { + "character_id": character_slug, + "character_name": character_name, + "identity": { + "base_specs": lora_data.get('lora_triggers', ''), + "hair": "", + "eyes": "", + "hands": "", + "arms": "", + "torso": "", + "pelvis": "", + "legs": "", + "feet": "", + "extra": "" + }, + "defaults": { + "expression": "", + "pose": "", + "scene": "" + }, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + }, + "styles": { + "aesthetic": "", + "primary_color": "", + "secondary_color": "", + "tertiary_color": "" + }, + "lora": lora_data, + "tags": look.data.get('tags', []) + } + + # Save character JSON + char_path = os.path.join(app.config['CHARACTERS_DIR'], f"{character_slug}.json") + try: + with open(char_path, 'w') as f: + json.dump(character_data, f, indent=2) + except Exception as e: + flash(f'Failed to save character file: {e}', 'error') + return redirect(url_for('look_detail', slug=slug)) + + # Create DB entry + character = Character( + character_id=character_slug, + slug=character_slug, + name=character_name, + data=character_data + ) + db.session.add(character) + db.session.commit() + + # Link the look to this character + look.character_id = character_slug + db.session.commit() + + flash(f'Character "{character_name}" created from look!', 'success') + return redirect(url_for('detail', slug=character_slug)) + + @app.route('/look//save_json', methods=['POST']) + def save_look_json(slug): + look = Look.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + look.data = new_data + look.character_id = new_data.get('character_id', look.character_id) + flag_modified(look, 'data') + db.session.commit() + if look.filename: + file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} + + @app.route('/look/create', methods=['GET', 'POST']) + def create_look(): + characters = Character.query.order_by(Character.name).all() + loras = get_available_loras('characters') + 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()] + + data = { + 'look_id': look_id, + 'look_name': name, + 'character_id': character_id, + 'positive': positive, + 'negative': negative, + 'lora': {'lora_name': lora_name, 'lora_weight': lora_weight, 'lora_triggers': lora_triggers}, + 'tags': tags + } + + 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() + + flash(f'Look "{name}" created!') + return redirect(url_for('look_detail', slug=slug)) + + return render_template('looks/create.html', characters=characters, loras=loras) + + @app.route('/get_missing_looks') + def get_missing_looks(): + missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all() + return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} + + @app.route('/clear_all_look_covers', methods=['POST']) + def clear_all_look_covers(): + looks = Look.query.all() + for look in looks: + look.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/looks/bulk_create', methods=['POST']) + def bulk_create_looks_from_loras(): + _s = Settings.query.first() + lora_dir = ((_s.lora_dir_characters if _s else None) or app.config['LORA_DIR']).rstrip('/') + _lora_subfolder = os.path.basename(lora_dir) + if not os.path.exists(lora_dir): + flash('Looks LoRA directory not found.', 'error') + return redirect(url_for('looks_index')) + + overwrite = request.form.get('overwrite') == 'true' + created_count = 0 + skipped_count = 0 + overwritten_count = 0 + + 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')) + + for filename in os.listdir(lora_dir): + if not filename.endswith('.safetensors'): + continue + + name_base = filename.rsplit('.', 1)[0] + look_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) + 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) + + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped_count += 1 + continue + + html_filename = f"{name_base}.html" + html_path = os.path.join(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}") + + 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###" + + 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 + + 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) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + time.sleep(0.5) + + except Exception as e: + print(f"Error creating look for {filename}: {e}") + + 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.') + + return redirect(url_for('looks_index')) \ No newline at end of file diff --git a/routes/outfits.py b/routes/outfits.py new file mode 100644 index 0000000..4be22ee --- /dev/null +++ b/routes/outfits.py @@ -0,0 +1,604 @@ +import json +import os +import re +import time +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +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.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 +from utils import allowed_file, _LORA_DEFAULTS +from services.llm import load_prompt, call_llm + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/get_missing_outfits') + def get_missing_outfits(): + missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all() + return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]} + + @app.route('/clear_all_outfit_covers', methods=['POST']) + def clear_all_outfit_covers(): + outfits = Outfit.query.all() + for outfit in outfits: + outfit.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/outfits') + def outfits_index(): + outfits = Outfit.query.order_by(Outfit.name).all() + lora_assignments = _count_outfit_lora_assignments() + return render_template('outfits/index.html', outfits=outfits, lora_assignments=lora_assignments) + + @app.route('/outfits/rescan', methods=['POST']) + def rescan_outfits(): + sync_outfits() + flash('Database synced with outfit files.') + return redirect(url_for('outfits_index')) + + @app.route('/outfits/bulk_create', methods=['POST']) + def bulk_create_outfits_from_loras(): + _s = Settings.query.first() + 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): + 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: + flash('Outfit system prompt file not found.', 'error') + return redirect(url_for('outfits_index')) + + for filename in os.listdir(clothing_lora_dir): + if not filename.endswith('.safetensors'): + continue + + name_base = filename.rsplit('.', 1)[0] + outfit_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) + outfit_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).title() + + json_filename = f"{outfit_id}.json" + json_path = os.path.join(app.config['CLOTHING_DIR'], json_filename) + + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped_count += 1 + continue + + html_filename = f"{name_base}.html" + html_path = os.path.join(clothing_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}") + + 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###" + + llm_response = call_llm(prompt, system_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 + + 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 + + os.makedirs(app.config['CLOTHING_DIR'], exist_ok=True) + with open(json_path, 'w') as f: + json.dump(outfit_data, f, indent=2) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + time.sleep(0.5) + + except Exception as e: + print(f"Error creating outfit for {filename}: {e}") + + 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.') + + return redirect(url_for('outfits_index')) + + def _get_linked_characters_for_outfit(outfit): + """Get all characters that have this outfit assigned.""" + linked = [] + all_chars = Character.query.all() + for char in all_chars: + if char.assigned_outfit_ids and outfit.outfit_id in char.assigned_outfit_ids: + linked.append(char) + return linked + + + @app.route('/outfit/') + def outfit_detail(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Load state from session + preferences = session.get(f'prefs_outfit_{slug}') + preview_image = session.get(f'preview_outfit_{slug}') + selected_character = session.get(f'char_outfit_{slug}') + extra_positive = session.get(f'extra_pos_outfit_{slug}', '') + extra_negative = session.get(f'extra_neg_outfit_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"outfits/{slug}/{f}" for f in files] + + # Get linked characters + linked_characters = _get_linked_characters_for_outfit(outfit) + + return render_template('outfits/detail.html', outfit=outfit, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, existing_previews=existing_previews, + linked_characters=linked_characters, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/outfit//edit', methods=['GET', 'POST']) + def edit_outfit(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('outfits') # Use clothing LoRAs for outfits + + if request.method == 'POST': + try: + # 1. Update basic fields + outfit.name = request.form.get('outfit_name') + + # 2. Rebuild the data dictionary + new_data = outfit.data.copy() + new_data['outfit_name'] = outfit.name + + # Update outfit_id if provided + new_outfit_id = request.form.get('outfit_id', outfit.outfit_id) + new_data['outfit_id'] = new_outfit_id + + # Update wardrobe section + if 'wardrobe' in new_data: + for key in new_data['wardrobe'].keys(): + form_key = f"wardrobe_{key}" + if form_key in request.form: + new_data['wardrobe'][key] = request.form.get(form_key) + + # Update lora section + if 'lora' in new_data: + for key in new_data['lora'].keys(): + form_key = f"lora_{key}" + if form_key in request.form: + val = request.form.get(form_key) + if key == 'lora_weight': + try: val = float(val) + except: val = 0.8 + new_data['lora'][key] = val + + # LoRA weight randomization bounds + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + 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] + + outfit.data = new_data + flag_modified(outfit, "data") + + # 3. Write back to JSON file + outfit_file = outfit.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', outfit.outfit_id)}.json" + file_path = os.path.join(app.config['CLOTHING_DIR'], outfit_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Outfit profile updated successfully!') + return redirect(url_for('outfit_detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('outfits/edit.html', outfit=outfit, loras=loras) + + @app.route('/outfit//upload', methods=['POST']) + def upload_outfit_image(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create outfit subfolder + outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") + os.makedirs(outfit_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(outfit_folder, filename) + file.save(file_path) + + # Store relative path in DB + outfit.image_path = f"outfits/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('outfit_detail', slug=slug)) + + @app.route('/outfit//generate', methods=['POST']) + def generate_outfit_image(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get selected character (if any) + character_slug = request.form.get('character_slug', '') + character = None + + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'prefs_outfit_{slug}'] = selected_fields + session[f'char_outfit_{slug}'] = character_slug + session[f'extra_pos_outfit_{slug}'] = extra_positive + session[f'extra_neg_outfit_{slug}'] = extra_negative + session.modified = True + + # Build combined data for prompt building + if character: + # Combine character identity/defaults with outfit wardrobe + combined_data = { + 'character_id': character.character_id, + 'identity': character.data.get('identity', {}), + 'defaults': character.data.get('defaults', {}), + 'wardrobe': outfit.data.get('wardrobe', {}), # Use outfit's wardrobe + 'styles': character.data.get('styles', {}), # Use character's styles + 'lora': outfit.data.get('lora', {}), # Use outfit's lora + 'tags': outfit.data.get('tags', []) + } + + # Merge character identity/defaults into selected_fields so they appear in the prompt + if selected_fields: + _ensure_character_fields(character, selected_fields, + include_wardrobe=False, include_defaults=True) + else: + # No explicit field selection (e.g. batch generation) — build a selection + # that includes identity + wardrobe + name + lora triggers, but NOT character + # defaults (expression, pose, scene), so outfit covers stay generic. + for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + outfit_wardrobe = outfit.data.get('wardrobe', {}) + for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + if outfit_wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + selected_fields.append('special::name') + if outfit.data.get('lora', {}).get('lora_triggers'): + selected_fields.append('lora::lora_triggers') + + default_fields = character.default_fields + else: + # Outfit only - no character + combined_data = { + 'character_id': outfit.outfit_id, + 'wardrobe': outfit.data.get('wardrobe', {}), + 'lora': outfit.data.get('lora', {}), + 'tags': outfit.data.get('tags', []) + } + default_fields = outfit.default_fields + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Queue generation + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + # Build prompts for combined data + prompts = build_prompt(combined_data, selected_fields, default_fields) + + _append_background(prompts, character) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + # Prepare workflow - pass both character and outfit for dual LoRA support + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, outfit=outfit, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + + char_label = character.name if character else 'no character' + label = f"Outfit: {outfit.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('outfits', slug, Outfit, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + return redirect(url_for('outfit_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('outfit_detail', slug=slug)) + + @app.route('/outfit//replace_cover_from_preview', methods=['POST']) + def replace_outfit_cover_from_preview(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + outfit.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('outfit_detail', slug=slug)) + + @app.route('/outfit/create', methods=['GET', 'POST']) + def create_outfit(): + 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' + + # Auto-generate slug from name if not provided + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + # Validate slug + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'outfit' + + # Find available filename (increment if exists) + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + # Check if LLM generation is requested + if use_llm: + if not prompt: + flash("Description is required when AI generation is enabled.") + return redirect(request.url) + + # 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) + + try: + llm_response = call_llm(f"Create an outfit profile for '{name}' based on this description: {prompt}", system_prompt) + + # Clean response (remove markdown if present) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + outfit_data = json.loads(clean_json) + + # Enforce IDs + outfit_data['outfit_id'] = safe_slug + outfit_data['outfit_name'] = name + + # Ensure required fields exist + if 'wardrobe' not in outfit_data: + outfit_data['wardrobe'] = { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + } + if 'lora' not in outfit_data: + outfit_data['lora'] = { + "lora_name": "", + "lora_weight": 0.8, + "lora_triggers": "" + } + if 'tags' not in outfit_data: + outfit_data['tags'] = [] + + except Exception as e: + print(f"LLM error: {e}") + flash(f"Failed to generate outfit profile: {e}") + return redirect(request.url) + else: + # Create blank outfit template + outfit_data = { + "outfit_id": safe_slug, + "outfit_name": name, + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 0.8, + "lora_triggers": "" + }, + "tags": [] + } + + try: + # Save file + file_path = os.path.join(app.config['CLOTHING_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(outfit_data, f, indent=2) + + # Add to DB + new_outfit = Outfit( + outfit_id=safe_slug, + slug=safe_slug, + filename=f"{safe_slug}.json", + name=name, + data=outfit_data + ) + db.session.add(new_outfit) + db.session.commit() + + flash('Outfit created successfully!') + return redirect(url_for('outfit_detail', slug=safe_slug)) + + 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') + + @app.route('/outfit//save_defaults', methods=['POST']) + def save_outfit_defaults(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + outfit.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this outfit!') + return redirect(url_for('outfit_detail', slug=slug)) + + @app.route('/outfit//clone', methods=['POST']) + def clone_outfit(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + + # Find the next available number for the clone + base_id = outfit.outfit_id + # Extract base name without number suffix + import re + match = re.match(r'^(.+?)_(\d+)$', base_id) + if match: + base_name = match.group(1) + current_num = int(match.group(2)) + else: + base_name = base_id + current_num = 1 + + # Find next available number + next_num = current_num + 1 + while True: + new_id = f"{base_name}_{next_num:02d}" + new_filename = f"{new_id}.json" + new_path = os.path.join(app.config['CLOTHING_DIR'], new_filename) + if not os.path.exists(new_path): + break + next_num += 1 + + # Create new outfit data (copy of original) + new_data = outfit.data.copy() + new_data['outfit_id'] = new_id + new_data['outfit_name'] = f"{outfit.name} (Copy)" + + # Save the new JSON file + with open(new_path, 'w') as f: + json.dump(new_data, f, indent=2) + + # Create new outfit in database + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + new_outfit = Outfit( + outfit_id=new_id, + slug=new_slug, + filename=new_filename, + name=new_data['outfit_name'], + data=new_data + ) + db.session.add(new_outfit) + db.session.commit() + + flash(f'Outfit cloned as "{new_id}"!') + return redirect(url_for('outfit_detail', slug=new_slug)) + + @app.route('/outfit//save_json', methods=['POST']) + def save_outfit_json(slug): + outfit = Outfit.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + outfit.data = new_data + flag_modified(outfit, 'data') + db.session.commit() + if outfit.filename: + file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} diff --git a/routes/presets.py b/routes/presets.py new file mode 100644 index 0000000..c149fab --- /dev/null +++ b/routes/presets.py @@ -0,0 +1,439 @@ +import json +import os +import re +import logging +import random +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings +from sqlalchemy.orm.attributes import flag_modified +from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background +from services.workflow import _prepare_workflow, _get_default_checkpoint +from services.job_queue import _enqueue_job, _make_finalize +from services.sync import sync_presets, _resolve_preset_entity, _resolve_preset_fields, _PRESET_ENTITY_MAP +from services.llm import load_prompt, call_llm +from utils import allowed_file + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/presets') + def presets_index(): + presets = Preset.query.order_by(Preset.filename).all() + return render_template('presets/index.html', presets=presets) + + @app.route('/preset/') + def preset_detail(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + preview_path = session.get(f'preview_preset_{slug}') + extra_positive = session.get(f'extra_pos_preset_{slug}', '') + extra_negative = session.get(f'extra_neg_preset_{slug}', '') + return render_template('presets/detail.html', preset=preset, preview_path=preview_path, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/preset//generate', methods=['POST']) + def generate_preset_image(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + + try: + action = request.form.get('action', 'preview') + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + session[f'extra_pos_preset_{slug}'] = extra_positive + session[f'extra_neg_preset_{slug}'] = extra_negative + session.modified = True + + data = preset.data + + # Resolve entities + char_cfg = data.get('character', {}) + character = _resolve_preset_entity('character', char_cfg.get('character_id')) + if not character: + character = Character.query.order_by(db.func.random()).first() + + outfit_cfg = data.get('outfit', {}) + action_cfg = data.get('action', {}) + style_cfg = data.get('style', {}) + scene_cfg = data.get('scene', {}) + detailer_cfg = data.get('detailer', {}) + look_cfg = data.get('look', {}) + ckpt_cfg = data.get('checkpoint', {}) + + outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id')) + action_obj = _resolve_preset_entity('action', action_cfg.get('action_id')) + style_obj = _resolve_preset_entity('style', style_cfg.get('style_id')) + scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id')) + detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id')) + look_obj = _resolve_preset_entity('look', look_cfg.get('look_id')) + + # Checkpoint: preset override or session default + preset_ckpt = ckpt_cfg.get('checkpoint_path') + if preset_ckpt == 'random': + ckpt_obj = Checkpoint.query.order_by(db.func.random()).first() + ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None + ckpt_data = ckpt_obj.data if ckpt_obj else None + elif preset_ckpt: + ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first() + ckpt_path = preset_ckpt + ckpt_data = ckpt_obj.data if ckpt_obj else None + else: + ckpt_path, ckpt_data = _get_default_checkpoint() + + # Resolve selected fields from preset toggles + selected_fields = _resolve_preset_fields(data) + + # 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 {} + + combined_data = { + 'character_id': character.character_id if character else 'unknown', + 'identity': character.data.get('identity', {}) if character else {}, + 'defaults': character.data.get('defaults', {}) if character else {}, + 'wardrobe': wardrobe_source, + '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 + extras_parts = [] + if action_obj: + action_fields = action_cfg.get('fields', {}) + for key in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']: + val_cfg = action_fields.get(key, True) + if val_cfg == 'random': + val_cfg = random.choice([True, False]) + if val_cfg: + val = action_obj.data.get('action', {}).get(key, '') + if val: + extras_parts.append(val) + if action_cfg.get('use_lora', True): + 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'): + extras_parts.append(f"by {s['artist_name']}") + if s.get('artistic_style'): + extras_parts.append(s['artistic_style']) + if style_cfg.get('use_lora', True): + trg = style_obj.data.get('lora', {}).get('lora_triggers', '') + if trg: + extras_parts.append(trg) + if scene_obj: + scene_fields = scene_cfg.get('fields', {}) + for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: + val_cfg = scene_fields.get(key, True) + if val_cfg == 'random': + val_cfg = random.choice([True, False]) + if val_cfg: + val = scene_obj.data.get('scene', {}).get(key, '') + if val: + extras_parts.append(val) + if scene_cfg.get('use_lora', True): + 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): + extras_parts.extend(p for p in prompt_val if p) + elif prompt_val: + extras_parts.append(prompt_val) + if detailer_cfg.get('use_lora', True): + trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '') + if trg: + extras_parts.append(trg) + + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + prompts = build_prompt(combined_data, selected_fields, default_fields=None, + active_outfit=active_wardrobe) + if extras_parts: + extra_str = ', '.join(filter(None, extras_parts)) + prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + workflow = _prepare_workflow( + workflow, character, prompts, + checkpoint=ckpt_path, checkpoint_data=ckpt_data, + custom_negative=extra_negative or None, + outfit=outfit if outfit_cfg.get('use_lora', True) else None, + action=action_obj if action_cfg.get('use_lora', True) else None, + style=style_obj if style_cfg.get('use_lora', True) else None, + scene=scene_obj if scene_cfg.get('use_lora', True) else None, + detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None, + look=look_obj, + fixed_seed=fixed_seed, + ) + + label = f"Preset: {preset.name} – {action}" + job = _enqueue_job(label, workflow, _make_finalize('presets', slug, Preset, action)) + + session[f'preview_preset_{slug}'] = None + session.modified = True + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + return redirect(url_for('preset_detail', slug=slug)) + + except Exception as e: + logger.exception("Generation error (preset %s): %s", slug, e) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('preset_detail', slug=slug)) + + @app.route('/preset//replace_cover_from_preview', methods=['POST']) + def replace_preset_cover_from_preview(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)): + preset.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('preset_detail', slug=slug)) + + @app.route('/preset//upload', methods=['POST']) + def upload_preset_image(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + if 'image' not in request.files: + flash('No file uploaded.') + return redirect(url_for('preset_detail', slug=slug)) + file = request.files['image'] + if file.filename == '': + flash('No file selected.') + return redirect(url_for('preset_detail', slug=slug)) + filename = secure_filename(file.filename) + folder = os.path.join(current_app.config['UPLOAD_FOLDER'], f'presets/{slug}') + os.makedirs(folder, exist_ok=True) + file.save(os.path.join(folder, filename)) + preset.image_path = f'presets/{slug}/{filename}' + db.session.commit() + flash('Image uploaded!') + return redirect(url_for('preset_detail', slug=slug)) + + @app.route('/preset//edit', methods=['GET', 'POST']) + def edit_preset(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + if request.method == 'POST': + name = request.form.get('preset_name', preset.name) + preset.name = name + + def _tog(val): + """Convert form value ('true'/'false'/'random') to JSON toggle value.""" + if val == 'random': + return 'random' + return val == 'true' + + def _entity_id(val): + return val if val else None + + char_id = _entity_id(request.form.get('char_character_id')) + new_data = { + 'preset_id': preset.preset_id, + 'preset_name': name, + 'character': { + 'character_id': char_id, + 'use_lora': request.form.get('char_use_lora') == 'on', + 'fields': { + 'identity': {k: _tog(request.form.get(f'id_{k}', 'true')) + for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, + 'defaults': {k: _tog(request.form.get(f'def_{k}', 'false')) + for k in ['expression', 'pose', 'scene']}, + 'wardrobe': { + 'outfit': request.form.get('wardrobe_outfit', 'default') or 'default', + 'fields': {k: _tog(request.form.get(f'wd_{k}', 'true')) + for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}, + }, + }, + }, + 'outfit': {'outfit_id': _entity_id(request.form.get('outfit_id')), + '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', + 'fields': {k: _tog(request.form.get(f'act_{k}', 'true')) + for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, + 'style': {'style_id': _entity_id(request.form.get('style_id')), + 'use_lora': request.form.get('style_use_lora') == 'on'}, + 'scene': {'scene_id': _entity_id(request.form.get('scene_id')), + 'use_lora': request.form.get('scene_use_lora') == 'on', + 'fields': {k: _tog(request.form.get(f'scn_{k}', 'true')) + for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}}, + 'detailer': {'detailer_id': _entity_id(request.form.get('detailer_id')), + 'use_lora': request.form.get('detailer_use_lora') == 'on'}, + 'look': {'look_id': _entity_id(request.form.get('look_id'))}, + 'checkpoint': {'checkpoint_path': _entity_id(request.form.get('checkpoint_path'))}, + 'tags': [t.strip() for t in request.form.get('tags', '').split(',') if t.strip()], + } + + preset.data = new_data + flag_modified(preset, "data") + db.session.commit() + + if preset.filename: + file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + flash('Preset saved!') + return redirect(url_for('preset_detail', slug=slug)) + + characters = Character.query.order_by(Character.name).all() + outfits = Outfit.query.order_by(Outfit.name).all() + actions = Action.query.order_by(Action.name).all() + styles = Style.query.order_by(Style.name).all() + scenes = Scene.query.order_by(Scene.name).all() + detailers = Detailer.query.order_by(Detailer.name).all() + looks = Look.query.order_by(Look.name).all() + checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() + return render_template('presets/edit.html', preset=preset, + characters=characters, outfits=outfits, actions=actions, + styles=styles, scenes=scenes, detailers=detailers, + looks=looks, checkpoints=checkpoints) + + @app.route('/preset//save_json', methods=['POST']) + def save_preset_json(slug): + preset = Preset.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + preset.data = new_data + preset.name = new_data.get('preset_name', preset.name) + flag_modified(preset, "data") + db.session.commit() + if preset.filename: + file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} + except Exception as e: + return {'success': False, 'error': str(e)}, 400 + + @app.route('/preset//clone', methods=['POST']) + def clone_preset(slug): + original = Preset.query.filter_by(slug=slug).first_or_404() + new_data = dict(original.data) + + base_id = f"{original.preset_id}_copy" + new_id = base_id + counter = 1 + while Preset.query.filter_by(preset_id=new_id).first(): + new_id = f"{base_id}_{counter}" + counter += 1 + + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + new_data['preset_id'] = new_id + new_data['preset_name'] = f"{original.name} (Copy)" + new_filename = f"{new_id}.json" + + os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True) + with open(os.path.join(current_app.config['PRESETS_DIR'], new_filename), 'w') as f: + json.dump(new_data, f, indent=2) + + new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename, + name=new_data['preset_name'], data=new_data) + db.session.add(new_preset) + db.session.commit() + flash(f"Cloned as '{new_data['preset_name']}'") + return redirect(url_for('preset_detail', slug=new_slug)) + + @app.route('/presets/rescan', methods=['POST']) + def rescan_presets(): + sync_presets() + flash('Preset library synced.') + return redirect(url_for('presets_index')) + + @app.route('/preset/create', methods=['GET', 'POST']) + def create_preset(): + if request.method == 'POST': + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + use_llm = request.form.get('use_llm') == 'on' + + 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 + counter = 1 + while os.path.exists(os.path.join(current_app.config['PRESETS_DIR'], f"{safe_id}.json")): + safe_id = f"{base_id}_{counter}" + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', safe_id) + counter += 1 + + if use_llm and description: + system_prompt = load_prompt('preset_system.txt') + if not system_prompt: + flash('Preset system prompt file not found.', 'error') + return redirect(request.url) + try: + llm_response = call_llm( + f"Create a preset profile named '{name}' based on this description: {description}", + system_prompt + ) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + preset_data = json.loads(clean_json) + except Exception as e: + logger.exception("LLM error creating preset: %s", e) + flash(f"AI generation failed: {e}", 'error') + return redirect(request.url) + else: + preset_data = { + 'character': {'character_id': 'random', 'use_lora': True, + 'fields': { + 'identity': {k: True for k in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']}, + 'defaults': {k: False for k in ['expression', 'pose', 'scene']}, + 'wardrobe': {'outfit': 'default', + 'fields': {k: True for k in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']}}, + }}, + 'outfit': {'outfit_id': None, 'use_lora': True}, + 'action': {'action_id': None, 'use_lora': True, + 'fields': {k: True for k in ['full_body', 'additional', 'head', 'eyes', 'arms', 'hands']}}, + 'style': {'style_id': None, 'use_lora': True}, + 'scene': {'scene_id': None, 'use_lora': True, + 'fields': {k: True for k in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']}}, + 'detailer': {'detailer_id': None, 'use_lora': True}, + 'look': {'look_id': None}, + 'checkpoint': {'checkpoint_path': None}, + 'tags': [], + } + + preset_data['preset_id'] = safe_id + preset_data['preset_name'] = name + + os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True) + file_path = os.path.join(current_app.config['PRESETS_DIR'], f"{safe_id}.json") + with open(file_path, 'w') as f: + json.dump(preset_data, f, indent=2) + + new_preset = Preset(preset_id=safe_id, slug=safe_slug, + filename=f"{safe_id}.json", name=name, data=preset_data) + db.session.add(new_preset) + db.session.commit() + flash(f"Preset '{name}' created!") + return redirect(url_for('edit_preset', slug=safe_slug)) + + return render_template('presets/create.html') + + @app.route('/get_missing_presets') + def get_missing_presets(): + missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all() + return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]} diff --git a/routes/queue_api.py b/routes/queue_api.py new file mode 100644 index 0000000..16fdb12 --- /dev/null +++ b/routes/queue_api.py @@ -0,0 +1,98 @@ +import logging +from services.job_queue import ( + _job_queue_lock, _job_queue, _job_history, _queue_worker_event, +) + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/api/queue') + 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 + ] + 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')) + return {'count': count} + + @app.route('/api/queue//remove', methods=['POST']) + def api_queue_remove(job_id): + """Remove a pending or paused job from the queue.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + 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 + job['status'] = 'removed' + return {'status': 'ok'} + + @app.route('/api/queue//pause', methods=['POST']) + def api_queue_pause(job_id): + """Toggle pause/resume on a pending job.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + return {'error': 'Job not found'}, 404 + if job['status'] == 'pending': + job['status'] = 'paused' + elif job['status'] == 'paused': + job['status'] = 'pending' + _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).""" + 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 + logger.info("Cleared %d pending jobs from queue", removed_count) + return {'status': 'ok', 'removed_count': removed_count} + + @app.route('/api/queue//status') + def api_queue_job_status(job_id): + """Return the status of a specific job.""" + with _job_queue_lock: + job = _job_history.get(job_id) + if not job: + return {'error': 'Job not found'}, 404 + resp = { + 'id': job['id'], + 'label': job['label'], + 'status': job['status'], + 'error': job['error'], + 'comfy_prompt_id': job['comfy_prompt_id'], + } + if job.get('result'): + resp['result'] = job['result'] + return resp diff --git a/routes/scenes.py b/routes/scenes.py new file mode 100644 index 0000000..73521dd --- /dev/null +++ b/routes/scenes.py @@ -0,0 +1,540 @@ +import json +import os +import re +import time +import logging + +from flask import render_template, request, redirect, url_for, flash, session, current_app +from werkzeug.utils import secure_filename +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.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 +from services.llm import load_prompt, call_llm +from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.route('/get_missing_scenes') + def get_missing_scenes(): + missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all() + return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} + + @app.route('/clear_all_scene_covers', methods=['POST']) + def clear_all_scene_covers(): + scenes = Scene.query.all() + for scene in scenes: + scene.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/scenes') + def scenes_index(): + scenes = Scene.query.order_by(Scene.name).all() + return render_template('scenes/index.html', scenes=scenes) + + @app.route('/scenes/rescan', methods=['POST']) + def rescan_scenes(): + sync_scenes() + flash('Database synced with scene files.') + return redirect(url_for('scenes_index')) + + @app.route('/scene/') + def scene_detail(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Load state from session + preferences = session.get(f'prefs_scene_{slug}') + preview_image = session.get(f'preview_scene_{slug}') + selected_character = session.get(f'char_scene_{slug}') + extra_positive = session.get(f'extra_pos_scene_{slug}', '') + extra_negative = session.get(f'extra_neg_scene_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"scenes/{slug}/{f}" for f in files] + + return render_template('scenes/detail.html', scene=scene, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, existing_previews=existing_previews, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/scene//edit', methods=['GET', 'POST']) + def edit_scene(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('scenes') + + if request.method == 'POST': + try: + # 1. Update basic fields + scene.name = request.form.get('scene_name') + + # 2. Rebuild the data dictionary + new_data = scene.data.copy() + new_data['scene_name'] = scene.name + + # Update scene section + if 'scene' in new_data: + for key in new_data['scene'].keys(): + form_key = f"scene_{key}" + if form_key in request.form: + val = request.form.get(form_key) + # Handle list for furniture/colors if they were originally lists + if key in ['furniture', 'colors'] and isinstance(new_data['scene'][key], list): + val = [v.strip() for v in val.split(',') if v.strip()] + new_data['scene'][key] = val + + # Update lora section + if 'lora' in new_data: + for key in new_data['lora'].keys(): + form_key = f"lora_{key}" + if form_key in request.form: + val = request.form.get(form_key) + if key == 'lora_weight': + try: val = float(val) + except: val = 1.0 + new_data['lora'][key] = val + + # LoRA weight randomization bounds + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + 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()] + + scene.data = new_data + flag_modified(scene, "data") + + # 3. Write back to JSON file + scene_file = scene.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', scene.scene_id)}.json" + file_path = os.path.join(app.config['SCENES_DIR'], scene_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Scene updated successfully!') + return redirect(url_for('scene_detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('scenes/edit.html', scene=scene, loras=loras) + + @app.route('/scene//upload', methods=['POST']) + def upload_scene_image(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create scene subfolder + scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") + os.makedirs(scene_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(scene_folder, filename) + file.save(file_path) + + # Store relative path in DB + scene.image_path = f"scenes/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('scene_detail', slug=slug)) + + def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None, fixed_seed=None, extra_positive=None, extra_negative=None): + if character: + combined_data = character.data.copy() + combined_data['character_id'] = character.character_id + + # Update character's 'defaults' with scene details + scene_data = scene_obj.data.get('scene', {}) + + # Build scene tag string + scene_tags = [] + for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: + val = scene_data.get(key) + if val: + if isinstance(val, list): + scene_tags.extend(val) + else: + scene_tags.append(val) + + combined_data['defaults']['scene'] = ", ".join(scene_tags) + + # Merge scene lora triggers if present + scene_lora = scene_obj.data.get('lora', {}) + if scene_lora.get('lora_triggers'): + if 'lora' not in combined_data: combined_data['lora'] = {} + combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {scene_lora['lora_triggers']}" + + # Merge character identity and wardrobe fields into selected_fields + if selected_fields: + _ensure_character_fields(character, selected_fields) + else: + # Auto-include essential character fields (minimal set for batch/default generation) + selected_fields = [] + for key in ['base_specs', 'hair', 'eyes']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + selected_fields.append('special::name') + wardrobe = character.get_active_wardrobe() + for key in _WARDROBE_KEYS: + if wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + selected_fields.extend(['defaults::scene', 'lora::lora_triggers']) + + default_fields = scene_obj.default_fields + active_outfit = character.active_outfit + else: + # Scene only - no character + scene_data = scene_obj.data.get('scene', {}) + scene_tags = [] + for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']: + val = scene_data.get(key) + if val: + if isinstance(val, list): scene_tags.extend(val) + else: scene_tags.append(val) + + combined_data = { + 'character_id': scene_obj.scene_id, + 'defaults': { + 'scene': ", ".join(scene_tags) + }, + 'lora': scene_obj.data.get('lora', {}), + 'tags': scene_obj.data.get('tags', []) + } + if not selected_fields: + selected_fields = ['defaults::scene', 'lora::lora_triggers'] + default_fields = scene_obj.default_fields + active_outfit = 'default' + + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + # For scene generation, we want to ensure Node 20 is handled in _prepare_workflow + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, scene=scene_obj, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + return workflow + + @app.route('/scene//generate', methods=['POST']) + def generate_scene_image(slug): + scene_obj = Scene.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get selected character (if any) + character_slug = request.form.get('character_slug', '') + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'char_scene_{slug}'] = character_slug + session[f'prefs_scene_{slug}'] = selected_fields + session[f'extra_pos_scene_{slug}'] = extra_positive + session[f'extra_neg_scene_{slug}'] = extra_negative + session.modified = True + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Build workflow using helper + workflow = _queue_scene_generation(scene_obj, character, selected_fields, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative) + + char_label = character.name if character else 'no character' + label = f"Scene: {scene_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('scenes', slug, Scene, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + return redirect(url_for('scene_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('scene_detail', slug=slug)) + + @app.route('/scene//save_defaults', methods=['POST']) + def save_scene_defaults(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + scene.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this scene!') + return redirect(url_for('scene_detail', slug=slug)) + + @app.route('/scene//replace_cover_from_preview', methods=['POST']) + def replace_scene_cover_from_preview(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + scene.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('scene_detail', slug=slug)) + + @app.route('/scenes/bulk_create', methods=['POST']) + def bulk_create_scenes_from_loras(): + _s = Settings.query.first() + backgrounds_lora_dir = ((_s.lora_dir_scenes if _s else None) or '/ImageModels/lora/Illustrious/Backgrounds').rstrip('/') + _lora_subfolder = os.path.basename(backgrounds_lora_dir) + if not os.path.exists(backgrounds_lora_dir): + flash('Backgrounds LoRA directory not found.', 'error') + return redirect(url_for('scenes_index')) + + overwrite = request.form.get('overwrite') == 'true' + created_count = 0 + skipped_count = 0 + overwritten_count = 0 + + 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')) + + for filename in os.listdir(backgrounds_lora_dir): + if filename.endswith('.safetensors'): + name_base = filename.rsplit('.', 1)[0] + scene_id = re.sub(r'[^a-zA-Z0-9_]', '_', name_base.lower()) + 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) + + is_existing = os.path.exists(json_path) + if is_existing and not overwrite: + skipped_count += 1 + continue + + html_filename = f"{name_base}.html" + html_path = os.path.join(backgrounds_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() + # 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}") + + 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###" + + llm_response = call_llm(prompt, system_prompt) + + # Clean response + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + scene_data = json.loads(clean_json) + + # Enforce system values while preserving LLM-extracted metadata + scene_data['scene_id'] = scene_id + scene_data['scene_name'] = scene_name + + 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 + + with open(json_path, 'w') as f: + json.dump(scene_data, f, indent=2) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + # 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.') + + return redirect(url_for('scenes_index')) + + @app.route('/scene/create', methods=['GET', 'POST']) + def create_scene(): + if request.method == 'POST': + name = request.form.get('name') + slug = request.form.get('filename', '').strip() + + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'scene' + + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + scene_data = { + "scene_id": safe_slug, + "scene_name": name, + "scene": { + "background": "", + "foreground": "", + "furniture": [], + "colors": [], + "lighting": "", + "theme": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_triggers": "" + } + } + + try: + file_path = os.path.join(app.config['SCENES_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(scene_data, f, indent=2) + + new_scene = Scene( + scene_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", + name=name, data=scene_data + ) + db.session.add(new_scene) + db.session.commit() + + flash('Scene created successfully!') + return redirect(url_for('scene_detail', slug=safe_slug)) + 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') + + @app.route('/scene//clone', methods=['POST']) + def clone_scene(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + + base_id = scene.scene_id + import re + match = re.match(r'^(.+?)_(\d+)$', base_id) + if match: + base_name = match.group(1) + current_num = int(match.group(2)) + else: + base_name = base_id + current_num = 1 + + next_num = current_num + 1 + while True: + new_id = f"{base_name}_{next_num:02d}" + new_filename = f"{new_id}.json" + new_path = os.path.join(app.config['SCENES_DIR'], new_filename) + if not os.path.exists(new_path): + break + next_num += 1 + + new_data = scene.data.copy() + new_data['scene_id'] = new_id + new_data['scene_name'] = f"{scene.name} (Copy)" + + with open(new_path, 'w') as f: + json.dump(new_data, f, indent=2) + + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + new_scene = Scene( + scene_id=new_id, slug=new_slug, filename=new_filename, + name=new_data['scene_name'], data=new_data + ) + db.session.add(new_scene) + db.session.commit() + + flash(f'Scene cloned as "{new_id}"!') + return redirect(url_for('scene_detail', slug=new_slug)) + + @app.route('/scene//save_json', methods=['POST']) + def save_scene_json(slug): + scene = Scene.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + scene.data = new_data + flag_modified(scene, 'data') + db.session.commit() + if scene.filename: + file_path = os.path.join(app.config['SCENES_DIR'], scene.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} diff --git a/routes/settings.py b/routes/settings.py new file mode 100644 index 0000000..d3d1eca --- /dev/null +++ b/routes/settings.py @@ -0,0 +1,229 @@ +import json +import logging +import subprocess + +import requests +from flask import flash, redirect, render_template, request, session, url_for + +from models import Checkpoint, Settings, db + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + @app.context_processor + def inject_comfyui_ws_url(): + url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + # If the URL is localhost/127.0.0.1, replace it with the current request's host + # so that remote clients connect to the correct machine for WebSockets. + if '127.0.0.1' in url or 'localhost' in url: + host = request.host.split(':')[0] + url = url.replace('127.0.0.1', host).replace('localhost', host) + + # Convert http/https to ws/wss + ws_url = url.replace('http://', 'ws://').replace('https://', 'wss://') + return dict(COMFYUI_WS_URL=f"{ws_url}/ws") + + @app.context_processor + def inject_default_checkpoint(): + checkpoints = Checkpoint.query.order_by(Checkpoint.name).all() + return dict(all_checkpoints=checkpoints, default_checkpoint_path=session.get('default_checkpoint', '')) + + @app.route('/set_default_checkpoint', methods=['POST']) + def set_default_checkpoint(): + checkpoint_path = request.form.get('checkpoint_path', '') + session['default_checkpoint'] = checkpoint_path + session.modified = True + + # Persist to database Settings so it survives across server restarts + try: + settings = Settings.query.first() + if not settings: + settings = Settings() + db.session.add(settings) + settings.default_checkpoint = checkpoint_path + db.session.commit() + logger.info("Default checkpoint saved to database: %s", checkpoint_path) + except Exception as e: + logger.error(f"Failed to persist checkpoint to database: {e}") + db.session.rollback() + + # Also persist to comfy_workflow.json for backwards compatibility + try: + workflow_path = 'comfy_workflow.json' + with open(workflow_path, 'r') as f: + workflow = json.load(f) + + # Update node 4 (CheckpointLoaderSimple) with the new checkpoint + if '4' in workflow and 'inputs' in workflow['4']: + workflow['4']['inputs']['ckpt_name'] = checkpoint_path + + with open(workflow_path, 'w') as f: + json.dump(workflow, f, indent=2) + except Exception as e: + logger.error(f"Failed to persist checkpoint to workflow file: {e}") + + return {'status': 'ok'} + + + @app.route('/api/status/comfyui') + def api_status_comfyui(): + """Return whether ComfyUI is reachable.""" + url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + try: + resp = requests.get(f'{url}/system_stats', timeout=3) + if resp.ok: + return {'status': 'ok'} + except Exception: + pass + return {'status': 'error'} + + + @app.route('/api/comfyui/loaded_checkpoint') + def api_comfyui_loaded_checkpoint(): + """Return the checkpoint name from the most recently completed ComfyUI job.""" + url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + try: + resp = requests.get(f'{url}/history', timeout=3) + if not resp.ok: + return {'checkpoint': None} + history = resp.json() + if not history: + return {'checkpoint': None} + # Sort by timestamp descending, take the most recent job + latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) + # Node "4" is the checkpoint loader in the workflow + nodes = latest.get('prompt', [None, None, {}])[2] + ckpt_name = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') + return {'checkpoint': ckpt_name} + except Exception: + return {'checkpoint': None} + + + @app.route('/api/status/mcp') + def api_status_mcp(): + """Return whether the danbooru-mcp Docker container is running.""" + try: + result = subprocess.run( + ['docker', 'ps', '--filter', 'name=danbooru-mcp', '--format', '{{.Names}}'], + capture_output=True, text=True, timeout=5, + ) + if 'danbooru-mcp' in result.stdout: + return {'status': 'ok'} + except Exception: + pass + return {'status': 'error'} + + + @app.route('/api/status/llm') + def api_status_llm(): + """Return whether the configured LLM provider is reachable.""" + try: + settings = Settings.query.first() + if not settings: + return {'status': 'error', 'message': 'Settings not configured'} + + is_local = settings.llm_provider != 'openrouter' + + if not is_local: + # Check OpenRouter + if not settings.openrouter_api_key: + return {'status': 'error', 'message': 'API key not configured'} + + # Try to fetch models list as a lightweight check + headers = { + "Authorization": f"Bearer {settings.openrouter_api_key}", + } + resp = requests.get("https://openrouter.ai/api/v1/models", headers=headers, timeout=5) + if resp.ok: + return {'status': 'ok', 'provider': 'OpenRouter'} + else: + # Check local provider (Ollama or LMStudio) + if not settings.local_base_url: + return {'status': 'error', 'message': 'Base URL not configured'} + + # Try to reach the models endpoint + url = f"{settings.local_base_url.rstrip('/')}/models" + resp = requests.get(url, timeout=5) + if resp.ok: + return {'status': 'ok', 'provider': settings.llm_provider.title()} + except Exception as e: + return {'status': 'error', 'message': str(e)} + + return {'status': 'error'} + + + @app.route('/api/status/character-mcp') + def api_status_character_mcp(): + """Return whether the character-mcp Docker container is running.""" + try: + result = subprocess.run( + ['docker', 'ps', '--format', '{{.Names}}'], + capture_output=True, text=True, timeout=5, + ) + # Check if any container name contains 'character-mcp' + if any('character-mcp' in line for line in result.stdout.splitlines()): + return {'status': 'ok'} + except Exception: + pass + return {'status': 'error'} + + + @app.route('/get_openrouter_models', methods=['POST']) + def get_openrouter_models(): + api_key = request.form.get('api_key') + if not api_key: + return {'error': 'API key is required'}, 400 + + headers = {"Authorization": f"Bearer {api_key}"} + try: + response = requests.get("https://openrouter.ai/api/v1/models", headers=headers) + response.raise_for_status() + models = response.json().get('data', []) + # Return simplified list of models + return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]} + except Exception as e: + return {'error': str(e)}, 500 + + @app.route('/get_local_models', methods=['POST']) + def get_local_models(): + base_url = request.form.get('base_url') + if not base_url: + return {'error': 'Base URL is required'}, 400 + + try: + response = requests.get(f"{base_url.rstrip('/')}/models") + response.raise_for_status() + models = response.json().get('data', []) + # Ollama/LMStudio often follow the same structure as OpenAI + return {'models': [{'id': m['id'], 'name': m.get('name', m['id'])} for m in models]} + except Exception as e: + return {'error': str(e)}, 500 + + @app.route('/settings', methods=['GET', 'POST']) + def settings(): + settings = Settings.query.first() + if not settings: + settings = Settings() + db.session.add(settings) + db.session.commit() + + if request.method == 'POST': + settings.llm_provider = request.form.get('llm_provider', 'openrouter') + settings.openrouter_api_key = request.form.get('api_key') + settings.openrouter_model = request.form.get('model') + settings.local_base_url = request.form.get('local_base_url') + settings.local_model = request.form.get('local_model') + settings.lora_dir_characters = request.form.get('lora_dir_characters') or settings.lora_dir_characters + settings.lora_dir_outfits = request.form.get('lora_dir_outfits') or settings.lora_dir_outfits + settings.lora_dir_actions = request.form.get('lora_dir_actions') or settings.lora_dir_actions + settings.lora_dir_styles = request.form.get('lora_dir_styles') or settings.lora_dir_styles + settings.lora_dir_scenes = request.form.get('lora_dir_scenes') or settings.lora_dir_scenes + settings.lora_dir_detailers = request.form.get('lora_dir_detailers') or settings.lora_dir_detailers + settings.checkpoint_dirs = request.form.get('checkpoint_dirs') or settings.checkpoint_dirs + db.session.commit() + flash('Settings updated successfully!') + return redirect(url_for('settings')) + + return render_template('settings.html', settings=settings) diff --git a/routes/strengths.py b/routes/strengths.py new file mode 100644 index 0000000..539bb6c --- /dev/null +++ b/routes/strengths.py @@ -0,0 +1,400 @@ +import json +import os +import logging +import random +from flask import request, session, current_app +from models import db, Character, Look, Outfit, Action, Style, Scene, Detailer +from sqlalchemy.orm.attributes import flag_modified +from services.prompts import build_prompt, _dedup_tags, _cross_dedup_prompts +from services.workflow import _get_default_checkpoint, _apply_checkpoint_settings, _log_workflow_prompts +from services.job_queue import _enqueue_job +from services.comfyui import get_history, get_image + +logger = logging.getLogger('gaze') + +_STRENGTHS_MODEL_MAP = { + 'characters': Character, + 'looks': Look, + 'outfits': Outfit, + 'actions': Action, + 'styles': Style, + 'scenes': Scene, + 'detailers': Detailer, +} + +_CATEGORY_LORA_NODES = { + 'characters': '16', + 'looks': '16', + 'outfits': '17', + 'actions': '18', + 'styles': '19', + 'scenes': '19', + 'detailers': '19', +} + +_STRENGTHS_DATA_DIRS = { + 'characters': 'CHARACTERS_DIR', + 'looks': 'LOOKS_DIR', + 'outfits': 'CLOTHING_DIR', + 'actions': 'ACTIONS_DIR', + 'styles': 'STYLES_DIR', + 'scenes': 'SCENES_DIR', + 'detailers': 'DETAILERS_DIR', +} + + +def register_routes(app): + + def _get_character_data_without_lora(character): + """Extract character data excluding LoRA to prevent activation in strengths gallery.""" + if not character: + return None + return {k: v for k, v in character.data.items() if k != 'lora'} + + def _build_strengths_prompts(category, entity, character, action=None, extra_positive=''): + """Build main/face/hand prompt strings for the Strengths Gallery.""" + if category == 'characters': + return build_prompt(entity.data, [], entity.default_fields) + + if category == 'looks': + 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': ''} + look_pos = entity.data.get('positive', '') + look_triggers = entity.data.get('lora', {}).get('lora_triggers', '') + prefix_parts = [p for p in [look_triggers, look_pos] if p] + prefix = ', '.join(prefix_parts) + if prefix: + base['main'] = f"{prefix}, {base['main']}" if base['main'] else prefix + return base + + 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 = [] + hand_parts = [] + if character: + identity = character.data.get('identity', {}) + defaults = character.data.get('defaults', {}) + char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), + identity.get('eyes'), defaults.get('expression')] if v] + face_parts = [v for v in [identity.get('hair'), identity.get('eyes'), + defaults.get('expression')] if v] + hand_parts = [v for v in [wardrobe.get('hands'), wardrobe.get('gloves')] if v] + main_parts = ([outfit_triggers] if outfit_triggers else []) + char_parts + wardrobe_parts + tags + return { + 'main': _dedup_tags(', '.join(p for p in main_parts if p)), + 'face': _dedup_tags(', '.join(face_parts)), + 'hand': _dedup_tags(', '.join(hand_parts)), + } + + if category == 'actions': + action_data = entity.data.get('action', {}) + action_triggers = entity.data.get('lora', {}).get('lora_triggers', '') + tags = entity.data.get('tags', []) + pose_fields = ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional'] + pose_parts = [action_data.get(k, '') for k in pose_fields if action_data.get(k)] + expr_parts = [action_data.get(k, '') for k in ['head', 'eyes'] if action_data.get(k)] + char_parts = [] + face_parts = list(expr_parts) + hand_parts = [action_data.get('hands', '')] if action_data.get('hands') else [] + if character: + identity = character.data.get('identity', {}) + char_parts = [v for v in [identity.get('base_specs'), identity.get('hair'), + identity.get('eyes')] if v] + face_parts = [v for v in [identity.get('hair'), identity.get('eyes')] + expr_parts if v] + main_parts = ([action_triggers] if action_triggers else []) + char_parts + pose_parts + tags + return { + 'main': _dedup_tags(', '.join(p for p in main_parts if p)), + 'face': _dedup_tags(', '.join(face_parts)), + 'hand': _dedup_tags(', '.join(hand_parts)), + } + + # 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] + 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] + else: # detailers + det_prompt = entity.data.get('prompt', '') + entity_parts = [p for p in [entity_triggers, det_prompt] + tags 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': ''} + entity_str = ', '.join(entity_parts) + if entity_str: + base['main'] = f"{base['main']}, {entity_str}" if base['main'] else entity_str + + if action is not None: + action_data = action.data.get('action', {}) + action_parts = [action_data.get(k, '') for k in + ['full_body', 'arms', 'hands', 'torso', 'pelvis', 'legs', 'feet', 'additional', 'head', 'eyes'] + if action_data.get(k)] + action_str = ', '.join(action_parts) + if action_str: + base['main'] = f"{base['main']}, {action_str}" if base['main'] else action_str + + if extra_positive: + base['main'] = f"{base['main']}, {extra_positive}" if base['main'] else extra_positive + + return base + + def _prepare_strengths_workflow(workflow, category, entity, character, prompts, + checkpoint, ckpt_data, strength_value, fixed_seed, + custom_negative=''): + """Wire a ComfyUI workflow with ONLY the entity's LoRA active at a specific strength.""" + active_node = _CATEGORY_LORA_NODES.get(category, '16') + entity_lora = entity.data.get('lora', {}) + entity_lora_name = entity_lora.get('lora_name', '') + + if checkpoint and '4' in workflow: + workflow['4']['inputs']['ckpt_name'] = checkpoint + + if '5' in workflow: + workflow['5']['inputs']['width'] = 1024 + workflow['5']['inputs']['height'] = 1024 + + if '6' in workflow: + workflow['6']['inputs']['text'] = workflow['6']['inputs']['text'].replace( + '{{POSITIVE_PROMPT}}', prompts.get('main', '')) + if '14' in workflow: + workflow['14']['inputs']['text'] = workflow['14']['inputs']['text'].replace( + '{{FACE_PROMPT}}', prompts.get('face', '')) + if '15' in workflow: + workflow['15']['inputs']['text'] = workflow['15']['inputs']['text'].replace( + '{{HAND_PROMPT}}', prompts.get('hand', '')) + + if category == 'looks': + look_neg = entity.data.get('negative', '') + if look_neg and '7' in workflow: + workflow['7']['inputs']['text'] = f"{look_neg}, {workflow['7']['inputs']['text']}" + + if custom_negative and '7' in workflow: + workflow['7']['inputs']['text'] = f"{custom_negative}, {workflow['7']['inputs']['text']}" + + model_source = ['4', 0] + clip_source = ['4', 1] + + for node_id in ['16', '17', '18', '19']: + if node_id not in workflow: + continue + if node_id == active_node and entity_lora_name: + workflow[node_id]['inputs']['lora_name'] = entity_lora_name + workflow[node_id]['inputs']['strength_model'] = float(strength_value) + workflow[node_id]['inputs']['strength_clip'] = float(strength_value) + workflow[node_id]['inputs']['model'] = list(model_source) + workflow[node_id]['inputs']['clip'] = list(clip_source) + model_source = [node_id, 0] + clip_source = [node_id, 1] + + for consumer, needs_model, needs_clip in [ + ('3', True, False), + ('6', False, True), + ('7', False, True), + ('11', True, True), + ('13', True, True), + ('14', False, True), + ('15', False, True), + ]: + if consumer in workflow: + if needs_model: + workflow[consumer]['inputs']['model'] = list(model_source) + if needs_clip: + workflow[consumer]['inputs']['clip'] = list(clip_source) + + for seed_node in ['3', '11', '13']: + if seed_node in workflow: + workflow[seed_node]['inputs']['seed'] = int(fixed_seed) + + if ckpt_data: + workflow = _apply_checkpoint_settings(workflow, ckpt_data) + + sampler_name = workflow['3']['inputs'].get('sampler_name') + scheduler = workflow['3']['inputs'].get('scheduler') + for node_id in ['11', '13']: + if node_id in workflow: + if sampler_name: + workflow[node_id]['inputs']['sampler_name'] = sampler_name + if scheduler: + workflow[node_id]['inputs']['scheduler'] = scheduler + + pos_text, neg_text = _cross_dedup_prompts( + workflow['6']['inputs']['text'], + workflow['7']['inputs']['text'] + ) + workflow['6']['inputs']['text'] = pos_text + workflow['7']['inputs']['text'] = neg_text + + _log_workflow_prompts(f"_prepare_strengths_workflow [node={active_node} lora={entity_lora_name} @ {strength_value} seed={fixed_seed}]", workflow) + return workflow + + @app.route('/strengths///generate', methods=['POST']) + def strengths_generate(category, slug): + if category not in _STRENGTHS_MODEL_MAP: + return {'error': 'unknown category'}, 400 + + Model = _STRENGTHS_MODEL_MAP[category] + entity = Model.query.filter_by(slug=slug).first_or_404() + + try: + strength_value = float(request.form.get('strength_value', 1.0)) + fixed_seed = int(request.form.get('seed', random.randint(1, 10**15))) + + _singular = { + 'outfits': 'outfit', 'actions': 'action', 'styles': 'style', + 'scenes': 'scene', 'detailers': 'detailer', 'looks': 'look', + } + session_prefix = _singular.get(category, category) + char_slug = (request.form.get('character_slug') or + session.get(f'char_{session_prefix}_{slug}')) + + if category == 'characters': + character = entity + elif char_slug == '__random__': + character = Character.query.order_by(db.func.random()).first() + elif char_slug: + character = Character.query.filter_by(slug=char_slug).first() + else: + character = None + + print(f"[Strengths] char_slug={char_slug!r} → character={character.slug if character else 'none'}") + + action_obj = None + extra_positive = '' + extra_negative = '' + if category == 'detailers': + action_slug = session.get(f'action_detailer_{slug}') + if action_slug: + action_obj = Action.query.filter_by(slug=action_slug).first() + extra_positive = session.get(f'extra_pos_detailer_{slug}', '') + extra_negative = session.get(f'extra_neg_detailer_{slug}', '') + print(f"[Strengths] detailer session — char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}") + + prompts = _build_strengths_prompts(category, entity, character, + action=action_obj, extra_positive=extra_positive) + + checkpoint, ckpt_data = _get_default_checkpoint() + workflow_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'comfy_workflow.json') + with open(workflow_path, 'r') as f: + workflow = json.load(f) + + workflow = _prepare_strengths_workflow( + workflow, category, entity, character, prompts, + checkpoint, ckpt_data, strength_value, fixed_seed, + custom_negative=extra_negative + ) + + _category = category + _slug = slug + _strength_value = strength_value + _fixed_seed = fixed_seed + def _finalize(comfy_prompt_id, job): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id].get('outputs', {}) + img_data = None + for node_output in outputs.values(): + for img in node_output.get('images', []): + img_data = get_image(img['filename'], img.get('subfolder', ''), img.get('type', 'output')) + break + if img_data: + break + if not img_data: + raise Exception('no image in output') + strength_str = f"{_strength_value:.2f}".replace('.', '_') + upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], _category, _slug, 'strengths') + os.makedirs(upload_dir, exist_ok=True) + out_filename = f"strength_{strength_str}_seed_{_fixed_seed}.png" + out_path = os.path.join(upload_dir, out_filename) + with open(out_path, 'wb') as f: + f.write(img_data) + relative = f"{_category}/{_slug}/strengths/{out_filename}" + job['result'] = {'image_url': f"/static/uploads/{relative}", 'strength_value': _strength_value} + + label = f"Strengths: {entity.name} @ {strength_value:.2f}" + job = _enqueue_job(label, workflow, _finalize) + return {'status': 'queued', 'job_id': job['id']} + + except Exception as e: + print(f"[Strengths] generate error: {e}") + return {'error': str(e)}, 500 + + @app.route('/strengths///list') + def strengths_list(category, slug): + upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], category, slug, 'strengths') + if not os.path.isdir(upload_dir): + return {'images': []} + + images = [] + for fname in sorted(os.listdir(upload_dir)): + if not fname.endswith('.png'): + continue + try: + parts = fname.replace('strength_', '').split('_seed_') + strength_raw = parts[0] + strength_display = strength_raw.replace('_', '.') + except Exception: + strength_display = fname + images.append({ + 'url': f"/static/uploads/{category}/{slug}/strengths/{fname}", + 'strength': strength_display, + 'filename': fname, + }) + return {'images': images} + + @app.route('/strengths///clear', methods=['POST']) + def strengths_clear(category, slug): + upload_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], category, slug, 'strengths') + if os.path.isdir(upload_dir): + for fname in os.listdir(upload_dir): + fpath = os.path.join(upload_dir, fname) + if os.path.isfile(fpath): + os.remove(fpath) + return {'success': True} + + @app.route('/strengths///save_range', methods=['POST']) + def strengths_save_range(category, slug): + """Save lora_weight_min / lora_weight_max from the Strengths Gallery back to the entity JSON + DB.""" + if category not in _STRENGTHS_MODEL_MAP or category not in _STRENGTHS_DATA_DIRS: + return {'error': 'unknown category'}, 400 + + try: + min_w = float(request.form.get('min_weight', '')) + max_w = float(request.form.get('max_weight', '')) + except (ValueError, TypeError): + return {'error': 'invalid weight values'}, 400 + + if min_w > max_w: + min_w, max_w = max_w, min_w + + Model = _STRENGTHS_MODEL_MAP[category] + entity = Model.query.filter_by(slug=slug).first_or_404() + + data = dict(entity.data) + if 'lora' not in data or not isinstance(data.get('lora'), dict): + return {'error': 'entity has no lora section'}, 400 + + data['lora']['lora_weight_min'] = min_w + data['lora']['lora_weight_max'] = max_w + entity.data = data + flag_modified(entity, 'data') + + data_dir = current_app.config[_STRENGTHS_DATA_DIRS[category]] + filename = getattr(entity, 'filename', None) or f"{slug}.json" + file_path = os.path.join(data_dir, filename) + if os.path.exists(file_path): + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write('\n') + + db.session.commit() + return {'success': True, 'lora_weight_min': min_w, 'lora_weight_max': max_w} diff --git a/routes/styles.py b/routes/styles.py new file mode 100644 index 0000000..2d1f405 --- /dev/null +++ b/routes/styles.py @@ -0,0 +1,544 @@ +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 +from werkzeug.utils import secure_filename +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.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 +from services.llm import load_prompt, call_llm +from utils import allowed_file, _WARDROBE_KEYS + +logger = logging.getLogger('gaze') + + +def register_routes(app): + + def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None): + """Build and return a prepared ComfyUI workflow dict for a style generation.""" + if character: + combined_data = character.data.copy() + combined_data['character_id'] = character.character_id + combined_data['style'] = style_obj.data.get('style', {}) + + # Merge style lora triggers if present + style_lora = style_obj.data.get('lora', {}) + if style_lora.get('lora_triggers'): + if 'lora' not in combined_data: combined_data['lora'] = {} + combined_data['lora']['lora_triggers'] = f"{combined_data['lora'].get('lora_triggers', '')}, {style_lora['lora_triggers']}" + + # Merge character identity and wardrobe fields into selected_fields + if selected_fields: + _ensure_character_fields(character, selected_fields) + else: + # Auto-include essential character fields (minimal set for batch/default generation) + selected_fields = [] + for key in ['base_specs', 'hair', 'eyes']: + if character.data.get('identity', {}).get(key): + selected_fields.append(f'identity::{key}') + selected_fields.append('special::name') + wardrobe = character.get_active_wardrobe() + for key in _WARDROBE_KEYS: + if wardrobe.get(key): + selected_fields.append(f'wardrobe::{key}') + selected_fields.extend(['style::artist_name', 'style::artistic_style', 'lora::lora_triggers']) + + default_fields = style_obj.default_fields + active_outfit = character.active_outfit + else: + combined_data = { + 'character_id': style_obj.style_id, + 'style': style_obj.data.get('style', {}), + 'lora': style_obj.data.get('lora', {}), + 'tags': style_obj.data.get('tags', []) + } + if not selected_fields: + selected_fields = ['style::artist_name', 'style::artistic_style', 'lora::lora_triggers'] + default_fields = style_obj.default_fields + active_outfit = 'default' + + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + + prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) + + _append_background(prompts, character) + + if extra_positive: + prompts["main"] = f"{prompts['main']}, {extra_positive}" + + ckpt_path, ckpt_data = _get_default_checkpoint() + workflow = _prepare_workflow(workflow, character, prompts, style=style_obj, custom_negative=extra_negative or None, checkpoint=ckpt_path, checkpoint_data=ckpt_data, fixed_seed=fixed_seed) + return workflow + + @app.route('/styles') + def styles_index(): + styles = Style.query.order_by(Style.name).all() + return render_template('styles/index.html', styles=styles) + + @app.route('/styles/rescan', methods=['POST']) + def rescan_styles(): + sync_styles() + flash('Database synced with style files.') + return redirect(url_for('styles_index')) + + @app.route('/style/') + def style_detail(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + characters = Character.query.order_by(Character.name).all() + + # Load state from session + preferences = session.get(f'prefs_style_{slug}') + preview_image = session.get(f'preview_style_{slug}') + selected_character = session.get(f'char_style_{slug}') + extra_positive = session.get(f'extra_pos_style_{slug}', '') + extra_negative = session.get(f'extra_neg_style_{slug}', '') + + # List existing preview images + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") + existing_previews = [] + if os.path.isdir(upload_dir): + files = sorted([f for f in os.listdir(upload_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))], reverse=True) + existing_previews = [f"styles/{slug}/{f}" for f in files] + + return render_template('styles/detail.html', style=style, characters=characters, + preferences=preferences, preview_image=preview_image, + selected_character=selected_character, existing_previews=existing_previews, + extra_positive=extra_positive, extra_negative=extra_negative) + + @app.route('/style//edit', methods=['GET', 'POST']) + def edit_style(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + loras = get_available_loras('styles') + + if request.method == 'POST': + try: + # 1. Update basic fields + style.name = request.form.get('style_name') + + # 2. Rebuild the data dictionary + new_data = style.data.copy() + new_data['style_name'] = style.name + + # Update style section + if 'style' in new_data: + for key in new_data['style'].keys(): + form_key = f"style_{key}" + if form_key in request.form: + new_data['style'][key] = request.form.get(form_key) + + # Update lora section + if 'lora' in new_data: + for key in new_data['lora'].keys(): + form_key = f"lora_{key}" + if form_key in request.form: + val = request.form.get(form_key) + if key == 'lora_weight': + try: val = float(val) + except: val = 1.0 + new_data['lora'][key] = val + + # LoRA weight randomization bounds + for bound in ['lora_weight_min', 'lora_weight_max']: + val_str = request.form.get(f'lora_{bound}', '').strip() + if val_str: + try: + new_data.setdefault('lora', {})[bound] = float(val_str) + except ValueError: + pass + else: + new_data.setdefault('lora', {}).pop(bound, None) + + style.data = new_data + flag_modified(style, "data") + + # 3. Write back to JSON file + style_file = style.filename or f"{re.sub(r'[^a-zA-Z0-9_]', '', style.style_id)}.json" + file_path = os.path.join(app.config['STYLES_DIR'], style_file) + + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + db.session.commit() + flash('Style updated successfully!') + return redirect(url_for('style_detail', slug=slug)) + + except Exception as e: + print(f"Edit error: {e}") + flash(f"Error saving changes: {str(e)}") + + return render_template('styles/edit.html', style=style, loras=loras) + + @app.route('/style//upload', methods=['POST']) + def upload_style_image(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + # Create style subfolder + style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") + os.makedirs(style_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(style_folder, filename) + file.save(file_path) + + # Store relative path in DB + style.image_path = f"styles/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for('style_detail', slug=slug)) + + @app.route('/style//generate', methods=['POST']) + def generate_style_image(slug): + style_obj = Style.query.filter_by(slug=slug).first_or_404() + + try: + # Get action type + action = request.form.get('action', 'preview') + + # Get selected fields + selected_fields = request.form.getlist('include_field') + + # Get selected character (if any) + character_slug = request.form.get('character_slug', '') + character = None + + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug + + # Get additional prompts + extra_positive = request.form.get('extra_positive', '').strip() + extra_negative = request.form.get('extra_negative', '').strip() + + # Save preferences + session[f'char_style_{slug}'] = character_slug + session[f'prefs_style_{slug}'] = selected_fields + session[f'extra_pos_style_{slug}'] = extra_positive + session[f'extra_neg_style_{slug}'] = extra_negative + session.modified = True + + # Parse optional seed + seed_val = request.form.get('seed', '').strip() + fixed_seed = int(seed_val) if seed_val else None + + # Build workflow using helper (returns workflow dict, not prompt_response) + workflow = _build_style_workflow(style_obj, character, selected_fields, fixed_seed=fixed_seed, extra_positive=extra_positive, extra_negative=extra_negative) + + char_label = character.name if character else 'no character' + label = f"Style: {style_obj.name} ({char_label}) – {action}" + job = _enqueue_job(label, workflow, _make_finalize('styles', slug, Style, action)) + + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'status': 'queued', 'job_id': job['id']} + + return redirect(url_for('style_detail', slug=slug)) + + except Exception as e: + print(f"Generation error: {e}") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'error': str(e)}, 500 + flash(f"Error during generation: {str(e)}") + return redirect(url_for('style_detail', slug=slug)) + + @app.route('/style//save_defaults', methods=['POST']) + def save_style_defaults(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + style.default_fields = selected_fields + db.session.commit() + flash('Default prompt selection saved for this style!') + return redirect(url_for('style_detail', slug=slug)) + + @app.route('/style//replace_cover_from_preview', methods=['POST']) + def replace_style_cover_from_preview(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): + style.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for('style_detail', slug=slug)) + + @app.route('/get_missing_styles') + def get_missing_styles(): + missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all() + return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} + + @app.route('/get_missing_detailers') + def get_missing_detailers(): + missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all() + return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]} + + @app.route('/clear_all_detailer_covers', methods=['POST']) + def clear_all_detailer_covers(): + detailers = Detailer.query.all() + for detailer in detailers: + detailer.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/clear_all_style_covers', methods=['POST']) + def clear_all_style_covers(): + styles = Style.query.all() + for style in styles: + style.image_path = None + db.session.commit() + return {'success': True} + + @app.route('/styles/generate_missing', methods=['POST']) + def generate_missing_styles(): + missing = Style.query.filter( + (Style.image_path == None) | (Style.image_path == '') + ).order_by(Style.name).all() + + if not missing: + flash("No styles missing cover images.") + return redirect(url_for('styles_index')) + + all_characters = Character.query.all() + if not all_characters: + flash("No characters available to preview styles with.", "error") + return redirect(url_for('styles_index')) + + enqueued = 0 + for style_obj in missing: + character = random.choice(all_characters) + try: + workflow = _build_style_workflow(style_obj, character=character) + + _enqueue_job(f"Style: {style_obj.name} – cover", workflow, + _make_finalize('styles', style_obj.slug, Style)) + enqueued += 1 + except Exception as e: + print(f"Error queuing cover generation for style {style_obj.name}: {e}") + + flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.") + return redirect(url_for('styles_index')) + + @app.route('/styles/bulk_create', methods=['POST']) + def bulk_create_styles_from_loras(): + _s = Settings.query.first() + 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): + 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: + 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() + + 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_count += 1 + continue + + 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}") + + 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###" + + llm_response = call_llm(prompt, system_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 + + if 'lora' not in style_data: style_data['lora'] = {} + style_data['lora']['lora_name'] = f"Illustrious/{_lora_subfolder}/{filename}" + + if not style_data['lora'].get('lora_triggers'): + style_data['lora']['lora_triggers'] = name_base + 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: + style_data['lora']['lora_weight_min'] = 0.7 + 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: + json.dump(style_data, f, indent=2) + + if is_existing: + overwritten_count += 1 + else: + created_count += 1 + + time.sleep(0.5) + except Exception as e: + print(f"Error creating style for {filename}: {e}") + + 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.') + + return redirect(url_for('styles_index')) + + @app.route('/style/create', methods=['GET', 'POST']) + def create_style(): + if request.method == 'POST': + name = request.form.get('name') + slug = request.form.get('filename', '').strip() + + if not slug: + slug = re.sub(r'[^a-zA-Z0-9]+', '_', name.lower()).strip('_') + + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', slug) + if not safe_slug: + safe_slug = 'style' + + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + style_data = { + "style_id": safe_slug, + "style_name": name, + "style": { + "artist_name": "", + "artistic_style": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 1.0, + "lora_triggers": "" + } + } + + try: + file_path = os.path.join(app.config['STYLES_DIR'], f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(style_data, f, indent=2) + + new_style = Style( + style_id=safe_slug, slug=safe_slug, filename=f"{safe_slug}.json", + name=name, data=style_data + ) + db.session.add(new_style) + db.session.commit() + + flash('Style created successfully!') + return redirect(url_for('style_detail', slug=safe_slug)) + 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') + + @app.route('/style//clone', methods=['POST']) + def clone_style(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + + base_id = style.style_id + match = re.match(r'^(.+?)_(\d+)$', base_id) + if match: + base_name = match.group(1) + current_num = int(match.group(2)) + else: + base_name = base_id + current_num = 1 + + next_num = current_num + 1 + while True: + new_id = f"{base_name}_{next_num:02d}" + new_filename = f"{new_id}.json" + new_path = os.path.join(app.config['STYLES_DIR'], new_filename) + if not os.path.exists(new_path): + break + next_num += 1 + + new_data = style.data.copy() + new_data['style_id'] = new_id + new_data['style_name'] = f"{style.name} (Copy)" + + with open(new_path, 'w') as f: + json.dump(new_data, f, indent=2) + + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + new_style = Style( + style_id=new_id, slug=new_slug, filename=new_filename, + name=new_data['style_name'], data=new_data + ) + db.session.add(new_style) + db.session.commit() + + flash(f'Style cloned as "{new_id}"!') + return redirect(url_for('style_detail', slug=new_slug)) + + @app.route('/style//save_json', methods=['POST']) + def save_style_json(slug): + style = Style.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + style.data = new_data + flag_modified(style, 'data') + db.session.commit() + if style.filename: + file_path = os.path.join(app.config['STYLES_DIR'], style.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} diff --git a/routes/transfer.py b/routes/transfer.py new file mode 100644 index 0000000..c9b1dff --- /dev/null +++ b/routes/transfer.py @@ -0,0 +1,340 @@ +import json +import os +import re +import logging +from flask import render_template, request, redirect, url_for, flash, session, current_app +from models import db, Character, Look, Outfit, Action, Style, Scene, Detailer, Settings +from sqlalchemy.orm.attributes import flag_modified +from utils import _LORA_DEFAULTS +from services.llm import load_prompt, call_llm + +logger = logging.getLogger('gaze') + +_RESOURCE_TRANSFER_MAP = { + 'looks': { + 'model_class': Look, + 'id_field': 'look_id', + 'target_dir': 'LOOKS_DIR', + 'index_route': 'looks_index', + 'detail_route': 'look_detail', + 'name_key': 'look_name', + 'id_key': 'look_id', + }, + 'outfits': { + 'model_class': Outfit, + 'id_field': 'outfit_id', + 'target_dir': 'CLOTHING_DIR', + 'index_route': 'outfits_index', + 'detail_route': 'outfit_detail', + 'name_key': 'outfit_name', + 'id_key': 'outfit_id', + }, + 'actions': { + 'model_class': Action, + 'id_field': 'action_id', + 'target_dir': 'ACTIONS_DIR', + 'index_route': 'actions_index', + 'detail_route': 'action_detail', + 'name_key': 'action_name', + 'id_key': 'action_id', + }, + 'styles': { + 'model_class': Style, + 'id_field': 'style_id', + 'target_dir': 'STYLES_DIR', + 'index_route': 'styles_index', + 'detail_route': 'style_detail', + 'name_key': 'style_name', + 'id_key': 'style_id', + }, + 'scenes': { + 'model_class': Scene, + 'id_field': 'scene_id', + 'target_dir': 'SCENES_DIR', + 'index_route': 'scenes_index', + 'detail_route': 'scene_detail', + 'name_key': 'scene_name', + 'id_key': 'scene_id', + }, + 'detailers': { + 'model_class': Detailer, + 'id_field': 'detailer_id', + 'target_dir': 'DETAILERS_DIR', + 'index_route': 'detailers_index', + 'detail_route': 'detailer_detail', + 'name_key': 'detailer_name', + 'id_key': 'detailer_id', + }, +} + +_TRANSFER_TARGET_CATEGORIES = ['looks', 'outfits', 'actions', 'styles', 'scenes', 'detailers'] + + +def register_routes(app): + + def _create_minimal_template(target_category, slug, name, source_data, source_name='resource'): + """Create a minimal template for the target category with basic fields.""" + templates = { + 'looks': { + 'look_id': slug, + 'look_name': name, + 'positive': source_data.get('positive', ''), + 'negative': source_data.get('negative', ''), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + 'outfits': { + 'outfit_id': slug, + 'outfit_name': name, + 'wardrobe': source_data.get('wardrobe', { + 'full_body': '', 'headwear': '', 'top': '', 'bottom': '', + 'legwear': '', 'footwear': '', 'hands': '', 'accessories': '' + }), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 0.8, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + 'actions': { + 'action_id': slug, + 'action_name': name, + 'action': source_data.get('action', { + 'full_body': '', 'head': '', 'eyes': '', 'arms': '', 'hands': '', + 'torso': '', 'pelvis': '', 'legs': '', 'feet': '', 'additional': '' + }), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + 'styles': { + 'style_id': slug, + 'style_name': name, + 'style': source_data.get('style', {'artist_name': '', 'artistic_style': ''}), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + 'scenes': { + 'scene_id': slug, + 'scene_name': name, + 'scene': source_data.get('scene', { + 'background': '', 'foreground': '', 'furniture': [], + 'colors': [], 'lighting': '', 'theme': '' + }), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + 'detailers': { + 'detailer_id': slug, + 'detailer_name': name, + 'prompt': source_data.get('prompt', source_data.get('positive', '')), + 'lora': source_data.get('lora', {'lora_name': '', 'lora_weight': 1.0, 'lora_triggers': ''}), + 'tags': source_data.get('tags', []), + 'description': f"Transferred from {source_name}" + }, + } + return templates.get(target_category, { + f'{target_category.rstrip("s")}_id': slug, + f'{target_category.rstrip("s")}_name': name, + 'description': f"Transferred from {source_name}", + 'tags': source_data.get('tags', []), + 'lora': source_data.get('lora', {}) + }) + + @app.route('/resource///transfer', methods=['GET', 'POST']) + def transfer_resource(category, slug): + """Generic resource transfer route.""" + if category not in _RESOURCE_TRANSFER_MAP: + flash(f'Invalid category: {category}') + return redirect(url_for('index')) + + source_config = _RESOURCE_TRANSFER_MAP[category] + model_class = source_config['model_class'] + id_field = source_config['id_field'] + + resource = model_class.query.filter_by(slug=slug).first_or_404() + resource_name = resource.name + resource_data = resource.data + + if request.method == 'POST': + target_category = request.form.get('target_category') + new_name = request.form.get('new_name', '').strip() + new_id = request.form.get('new_id', '').strip() + use_llm = request.form.get('use_llm') in ('on', 'true', '1', 'yes') or request.form.get('use_llm') is not None + transfer_lora = request.form.get('transfer_lora') == 'on' + remove_original = request.form.get('remove_original') == 'on' + + if not new_name: + flash('New name is required for transfer') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + + if len(new_name) > 100: + flash('New name must be 100 characters or less') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + + if not new_id: + new_id = re.sub(r'[^a-zA-Z0-9]+', '_', new_name.lower()).strip('_') + + safe_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + if not safe_slug: + safe_slug = 'transferred' + + if target_category not in _RESOURCE_TRANSFER_MAP: + flash('Invalid target category') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + + if target_category == category: + flash('Cannot transfer to the same category') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + + target_config = _RESOURCE_TRANSFER_MAP[target_category] + target_model_class = target_config['model_class'] + target_id_field = target_config['id_field'] + target_dir = current_app.config[target_config['target_dir']] + target_name_key = target_config['name_key'] + target_id_key = target_config['id_key'] + + base_slug = safe_slug + counter = 1 + while os.path.exists(os.path.join(target_dir, f"{safe_slug}.json")): + safe_slug = f"{base_slug}_{counter}" + counter += 1 + + if use_llm: + try: + category_system_prompts = { + 'outfits': 'outfit_system.txt', + 'actions': 'action_system.txt', + 'styles': 'style_system.txt', + 'scenes': 'scene_system.txt', + 'detailers': 'detailer_system.txt', + 'looks': 'look_system.txt', + } + system_prompt_file = category_system_prompts.get(target_category) + system_prompt = load_prompt(system_prompt_file) if system_prompt_file else None + + if not system_prompt: + system_prompt = load_prompt('transfer_system.txt') + + if not system_prompt: + system_prompt = f"""You are an AI assistant that creates {target_category.rstrip('s')} profiles for AI image generation. + +Your task is to create a {target_category.rstrip('s')} profile based on the source resource data provided. + +Target type: {target_category} + +Required JSON Structure: +- {target_id_key}: "{safe_slug}" +- {target_name_key}: "{new_name}" +""" + source_summary = json.dumps(resource_data, indent=2) + llm_prompt = f"""Create a {target_category.rstrip('s')} profile named "{new_name}" (ID: {safe_slug}) based on this source {category.rstrip('s')} data: + +Source {category.rstrip('s')} name: {resource_name} +Source data: +{source_summary} + +Generate a complete {target_category.rstrip('s')} profile with all required fields for the {target_category} category.""" + + llm_response = call_llm(llm_prompt, system_prompt) + clean_json = llm_response.replace('```json', '').replace('```', '').strip() + new_data = json.loads(clean_json) + new_data[target_id_key] = safe_slug + new_data[target_name_key] = new_name + + except Exception as e: + logger.exception(f"LLM transfer error: {e}") + flash(f'Failed to generate {target_category.rstrip("s")} with AI: {e}') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + else: + new_data = _create_minimal_template(target_category, safe_slug, new_name, resource_data, resource_name) + + try: + settings = Settings.query.first() + lora_moved = False + + if transfer_lora and 'lora' in resource_data and resource_data['lora'].get('lora_name'): + old_lora_path = resource_data['lora']['lora_name'] + + lora_dir_map = { + 'characters': getattr(settings, 'lora_dir_characters', None) or _LORA_DEFAULTS.get('characters', ''), + 'looks': getattr(settings, 'lora_dir_looks', None) or getattr(settings, 'lora_dir_characters', None) or _LORA_DEFAULTS.get('looks', _LORA_DEFAULTS.get('characters', '')), + 'outfits': getattr(settings, 'lora_dir_outfits', None) or _LORA_DEFAULTS.get('outfits', ''), + 'actions': getattr(settings, 'lora_dir_actions', None) or _LORA_DEFAULTS.get('actions', ''), + 'styles': getattr(settings, 'lora_dir_styles', None) or _LORA_DEFAULTS.get('styles', ''), + 'scenes': getattr(settings, 'lora_dir_scenes', None) or _LORA_DEFAULTS.get('scenes', ''), + 'detailers': getattr(settings, 'lora_dir_detailers', None) or _LORA_DEFAULTS.get('detailers', ''), + } + + target_lora_dir = lora_dir_map.get(target_category) + source_lora_dir = lora_dir_map.get(category) + + if old_lora_path and target_lora_dir and source_lora_dir: + lora_filename = os.path.basename(old_lora_path) + + abs_source_path = os.path.join('/ImageModels/lora', old_lora_path) + if not os.path.exists(abs_source_path): + abs_source_path = os.path.join(source_lora_dir, lora_filename) + + abs_target_path = os.path.join(target_lora_dir, lora_filename) + + if os.path.exists(abs_source_path): + try: + import shutil + os.makedirs(target_lora_dir, exist_ok=True) + shutil.move(abs_source_path, abs_target_path) + + target_subfolder = os.path.basename(target_lora_dir.rstrip('/')) + new_data['lora']['lora_name'] = f"Illustrious/{target_subfolder}/{lora_filename}" + lora_moved = True + flash(f'Moved LoRA file to {target_lora_dir}') + except Exception as lora_e: + logger.exception(f"LoRA move error: {lora_e}") + flash(f'Warning: Failed to move LoRA file: {lora_e}', 'warning') + else: + flash(f'Warning: Source LoRA file not found at {abs_source_path}', 'warning') + + file_path = os.path.join(target_dir, f"{safe_slug}.json") + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + + new_entity = target_model_class( + **{target_id_field: safe_slug}, + slug=safe_slug, + filename=f"{safe_slug}.json", + name=new_name, + data=new_data + ) + db.session.add(new_entity) + + if remove_original: + try: + source_dir = current_app.config[source_config['target_dir']] + orig_file_path = os.path.join(source_dir, resource.filename or f"{resource.slug}.json") + if os.path.exists(orig_file_path): + os.remove(orig_file_path) + db.session.delete(resource) + flash(f'Removed original {category.rstrip("s")}: {resource_name}') + except Exception as rm_e: + logger.exception(f"Error removing original: {rm_e}") + flash(f'Warning: Failed to remove original: {rm_e}', 'warning') + + db.session.commit() + flash(f'Successfully transferred to {target_category.rstrip("s")}: {new_name}') + return redirect(url_for(target_config['index_route'], highlight=safe_slug)) + + except Exception as e: + logger.exception(f"Transfer save error: {e}") + flash(f'Failed to save transferred {target_category.rstrip("s")}: {e}') + return redirect(url_for('transfer_resource', category=category, slug=slug)) + + available_targets = [(cat, cat.rstrip('s').replace('_', ' ').title()) + for cat in _TRANSFER_TARGET_CATEGORIES + if cat != category] + + return render_template('transfer_resource.html', + category=category, + resource=resource, + available_targets=available_targets, + cancel_route=source_config['detail_route']) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/comfyui.py b/services/comfyui.py new file mode 100644 index 0000000..26f152f --- /dev/null +++ b/services/comfyui.py @@ -0,0 +1,117 @@ +import json +import logging +import requests +from flask import current_app + +logger = logging.getLogger('gaze') + + +def _ensure_checkpoint_loaded(checkpoint_path): + """Check if the desired checkpoint is loaded in ComfyUI, and force reload if not.""" + if not checkpoint_path: + return + + try: + # Get currently loaded checkpoint from ComfyUI history + url = current_app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188') + resp = requests.get(f'{url}/history', timeout=3) + if resp.ok: + history = resp.json() + if history: + latest = max(history.values(), key=lambda j: j.get('status', {}).get('status_str', '')) + nodes = latest.get('prompt', [None, None, {}])[2] + loaded_ckpt = nodes.get('4', {}).get('inputs', {}).get('ckpt_name') + + # If the loaded checkpoint matches what we want, no action needed + if loaded_ckpt == checkpoint_path: + logger.info(f"Checkpoint {checkpoint_path} already loaded in ComfyUI") + return + + # Checkpoint doesn't match or couldn't determine - force unload all models + logger.info(f"Forcing ComfyUI to unload models to ensure {checkpoint_path} loads") + requests.post(f'{url}/free', json={'unload_models': True}, timeout=5) + except Exception as e: + logger.warning(f"Failed to check/force checkpoint reload: {e}") + + +def queue_prompt(prompt_workflow, client_id=None): + """POST a workflow to ComfyUI's /prompt endpoint.""" + # Ensure the checkpoint in the workflow is loaded in ComfyUI + checkpoint_path = prompt_workflow.get('4', {}).get('inputs', {}).get('ckpt_name') + _ensure_checkpoint_loaded(checkpoint_path) + + p = {"prompt": prompt_workflow} + if client_id: + p["client_id"] = client_id + + url = current_app.config['COMFYUI_URL'] + + # Log the full request being sent to ComfyUI + logger.debug("=" * 80) + logger.debug("COMFYUI REQUEST - Sending prompt to %s/prompt", url) + logger.debug("Checkpoint: %s", checkpoint_path) + logger.debug("Client ID: %s", client_id if client_id else "(none)") + logger.debug("Full workflow JSON:") + logger.debug(json.dumps(prompt_workflow, indent=2)) + logger.debug("=" * 80) + + data = json.dumps(p).encode('utf-8') + response = requests.post(f"{url}/prompt", data=data) + response_json = response.json() + + # Log the response from ComfyUI + logger.debug("COMFYUI RESPONSE - Status: %s", response.status_code) + logger.debug("Response JSON: %s", json.dumps(response_json, indent=2)) + if 'prompt_id' in response_json: + logger.info("ComfyUI accepted prompt with ID: %s", response_json['prompt_id']) + else: + logger.error("ComfyUI rejected prompt: %s", response_json) + logger.debug("=" * 80) + + return response_json + + +def get_history(prompt_id): + """Poll ComfyUI /history for results of a given prompt_id.""" + url = current_app.config['COMFYUI_URL'] + response = requests.get(f"{url}/history/{prompt_id}") + history_json = response.json() + + # Log detailed history response for debugging + if prompt_id in history_json: + logger.debug("=" * 80) + logger.debug("COMFYUI HISTORY - Prompt ID: %s", prompt_id) + logger.debug("Status: %s", response.status_code) + + # Extract key information from the history + prompt_data = history_json[prompt_id] + if 'status' in prompt_data: + logger.debug("Generation status: %s", prompt_data['status']) + + if 'outputs' in prompt_data: + logger.debug("Outputs available: %s", list(prompt_data['outputs'].keys())) + for node_id, output in prompt_data['outputs'].items(): + if 'images' in output: + logger.debug(" Node %s produced %d image(s)", node_id, len(output['images'])) + for img in output['images']: + logger.debug(" - %s (subfolder: %s, type: %s)", + img.get('filename'), img.get('subfolder'), img.get('type')) + + logger.debug("Full history response:") + logger.debug(json.dumps(history_json, indent=2)) + logger.debug("=" * 80) + else: + logger.debug("History not yet available for prompt ID: %s", prompt_id) + + return history_json + + +def get_image(filename, subfolder, folder_type): + """Retrieve a generated image from ComfyUI's /view endpoint.""" + url = current_app.config['COMFYUI_URL'] + data = {"filename": filename, "subfolder": subfolder, "type": folder_type} + logger.debug("Fetching image from ComfyUI: filename=%s, subfolder=%s, type=%s", + filename, subfolder, folder_type) + response = requests.get(f"{url}/view", params=data) + logger.debug("Image retrieved: %d bytes (status: %s)", len(response.content), response.status_code) + return response.content diff --git a/services/file_io.py b/services/file_io.py new file mode 100644 index 0000000..b6ea404 --- /dev/null +++ b/services/file_io.py @@ -0,0 +1,66 @@ +import os +from models import Settings, Character, Look +from utils import _LORA_DEFAULTS + + +def get_available_loras(category): + """Return sorted list of LoRA paths for the given category. + category: one of 'characters','outfits','actions','styles','scenes','detailers' + """ + settings = Settings.query.first() + lora_dir = (getattr(settings, f'lora_dir_{category}', None) if settings else None) or _LORA_DEFAULTS.get(category, '') + if not lora_dir or not os.path.isdir(lora_dir): + return [] + subfolder = os.path.basename(lora_dir.rstrip('/')) + return sorted(f"Illustrious/{subfolder}/{f}" for f in os.listdir(lora_dir) if f.endswith('.safetensors')) + + +def get_available_checkpoints(): + settings = Settings.query.first() + checkpoint_dirs_str = (settings.checkpoint_dirs if settings else None) or \ + '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob' + checkpoints = [] + for ckpt_dir in checkpoint_dirs_str.split(','): + ckpt_dir = ckpt_dir.strip() + if not ckpt_dir or not os.path.isdir(ckpt_dir): + continue + prefix = os.path.basename(ckpt_dir.rstrip('/')) + for f in os.listdir(ckpt_dir): + if f.endswith('.safetensors') or f.endswith('.ckpt'): + checkpoints.append(f"{prefix}/{f}") + return sorted(checkpoints) + + +def _count_look_assignments(): + """Return a dict mapping look_id to the count of characters it's assigned to.""" + assignment_counts = {} + looks = Look.query.all() + for look in looks: + if look.character_id: + assignment_counts[look.look_id] = 1 + else: + assignment_counts[look.look_id] = 0 + return assignment_counts + + +def _count_outfit_lora_assignments(): + """Return a dict mapping outfit LoRA filename to the count of characters using it.""" + assignment_counts = {} + characters = Character.query.all() + + for character in characters: + char_lora = character.data.get('lora', {}).get('lora_name', '') + if char_lora and 'Clothing' in char_lora: + assignment_counts[char_lora] = assignment_counts.get(char_lora, 0) + 1 + + wardrobe = character.data.get('wardrobe', {}) + if 'default' in wardrobe and isinstance(wardrobe.get('default'), dict): + for outfit_name, outfit_data in wardrobe.items(): + if isinstance(outfit_data, dict): + outfit_lora = outfit_data.get('lora', {}) + if isinstance(outfit_lora, dict): + lora_name = outfit_lora.get('lora_name', '') + if lora_name: + assignment_counts[lora_name] = assignment_counts.get(lora_name, 0) + 1 + + return assignment_counts diff --git a/services/job_queue.py b/services/job_queue.py new file mode 100644 index 0000000..0752cc5 --- /dev/null +++ b/services/job_queue.py @@ -0,0 +1,265 @@ +import os +import time +import uuid +import logging +import threading +from collections import deque + +from flask import current_app +from models import db +from services.comfyui import queue_prompt, get_history, get_image + +logger = logging.getLogger('gaze') + +# --------------------------------------------------------------------------- +# Generation Job Queue +# --------------------------------------------------------------------------- +# Each job is a dict: +# id — unique UUID string +# label — human-readable description (e.g. "Tifa Lockhart – preview") +# status — 'pending' | 'processing' | 'done' | 'failed' | 'paused' | 'removed' +# workflow — the fully-prepared ComfyUI workflow dict +# finalize_fn — callable(comfy_prompt_id, job) that saves the image; called after ComfyUI finishes +# error — error message string (when status == 'failed') +# result — dict with image_url etc. (set by finalize_fn on success) +# created_at — unix timestamp +# comfy_prompt_id — the prompt_id returned by ComfyUI (set when processing starts) + +_job_queue_lock = threading.Lock() +_job_queue = deque() # ordered list of job 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 + +# Stored reference to the Flask app, set by init_queue_worker() +_app = None + + +def _enqueue_job(label, workflow, finalize_fn): + """Add a generation job to the queue. Returns the job dict.""" + job = { + 'id': str(uuid.uuid4()), + 'label': label, + 'status': 'pending', + 'workflow': workflow, + 'finalize_fn': finalize_fn, + 'error': None, + 'result': None, + 'created_at': time.time(), + 'comfy_prompt_id': None, + } + with _job_queue_lock: + _job_queue.append(job) + _job_history[job['id']] = job + logger.info("Job queued: [%s] %s", job['id'][:8], label) + _queue_worker_event.set() + return job + + +def _queue_worker(): + """Background thread: processes jobs from _job_queue sequentially.""" + while True: + _queue_worker_event.wait() + _queue_worker_event.clear() + + while True: + job = None + with _job_queue_lock: + # Find the first pending job + for j in _job_queue: + if j['status'] == 'pending': + job = j + break + + if job is None: + break # No pending jobs — go back to waiting + + # Mark as processing + with _job_queue_lock: + job['status'] = 'processing' + + logger.info("=" * 80) + logger.info("JOB STARTED: [%s] %s", job['id'][:8], job['label']) + logger.info("Job created at: %s", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(job['created_at']))) + + # Log workflow summary before sending to ComfyUI + workflow = job['workflow'] + logger.info("Workflow summary:") + logger.info(" Checkpoint: %s", workflow.get('4', {}).get('inputs', {}).get('ckpt_name', '(not set)')) + logger.info(" Seed: %s", workflow.get('3', {}).get('inputs', {}).get('seed', '(not set)')) + logger.info(" Resolution: %sx%s", + workflow.get('5', {}).get('inputs', {}).get('width', '?'), + workflow.get('5', {}).get('inputs', {}).get('height', '?')) + logger.info(" Sampler: %s / %s (steps=%s, cfg=%s)", + workflow.get('3', {}).get('inputs', {}).get('sampler_name', '?'), + workflow.get('3', {}).get('inputs', {}).get('scheduler', '?'), + workflow.get('3', {}).get('inputs', {}).get('steps', '?'), + workflow.get('3', {}).get('inputs', {}).get('cfg', '?')) + + # Log active LoRAs + active_loras = [] + for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: + if node_id in workflow: + lora_name = workflow[node_id]["inputs"].get("lora_name", "") + if lora_name: + strength = workflow[node_id]["inputs"].get("strength_model", "?") + active_loras.append(f"{label_str}:{lora_name.split('/')[-1]}@{strength}") + logger.info(" Active LoRAs: %s", ' | '.join(active_loras) if active_loras else '(none)') + + # Log prompts + logger.info(" Positive prompt: %s", workflow.get('6', {}).get('inputs', {}).get('text', '(not set)')[:200]) + logger.info(" Negative prompt: %s", workflow.get('7', {}).get('inputs', {}).get('text', '(not set)')[:200]) + logger.info("=" * 80) + + try: + with _app.app_context(): + # Send workflow to ComfyUI + logger.info("Sending workflow to ComfyUI...") + prompt_response = queue_prompt(job['workflow']) + if 'prompt_id' not in prompt_response: + raise Exception(f"ComfyUI rejected job: {prompt_response.get('error', 'unknown error')}") + + comfy_id = prompt_response['prompt_id'] + with _job_queue_lock: + job['comfy_prompt_id'] = comfy_id + logger.info("Job [%s] queued in ComfyUI as %s", job['id'][:8], comfy_id) + + # Poll until done (max ~10 minutes) + max_retries = 300 + finished = False + poll_count = 0 + logger.info("Polling ComfyUI for completion (max %d retries, 2s interval)...", max_retries) + while max_retries > 0: + history = get_history(comfy_id) + if comfy_id in history: + finished = True + logger.info("Generation completed after %d polls (%d seconds)", + poll_count, poll_count * 2) + break + poll_count += 1 + if poll_count % 10 == 0: # Log every 20 seconds + logger.info("Still waiting for generation... (%d polls, %d seconds elapsed)", + poll_count, poll_count * 2) + time.sleep(2) + max_retries -= 1 + + if not finished: + raise Exception("ComfyUI generation timed out") + + logger.info("Job [%s] generation complete, finalizing...", job['id'][:8]) + # Run the finalize callback (saves image to disk / DB) + job['finalize_fn'](comfy_id, job) + + with _job_queue_lock: + job['status'] = 'done' + logger.info("=" * 80) + logger.info("JOB COMPLETED: [%s] %s", job['id'][:8], job['label']) + logger.info("=" * 80) + + except Exception as e: + logger.error("=" * 80) + logger.exception("JOB FAILED: [%s] %s — %s", job['id'][:8], job['label'], e) + logger.error("=" * 80) + with _job_queue_lock: + job['status'] = 'failed' + job['error'] = str(e) + + # Remove completed/failed jobs from the active queue (keep in history) + with _job_queue_lock: + try: + _job_queue.remove(job) + except ValueError: + pass # Already removed (e.g. by user) + + # Periodically purge old finished jobs from history to avoid unbounded growth + _prune_job_history() + + +def _make_finalize(category, slug, db_model_class=None, action=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 + """ + def _finalize(comfy_prompt_id, job): + logger.debug("=" * 80) + logger.debug("FINALIZE - Starting finalization for prompt ID: %s", comfy_prompt_id) + logger.debug("Category: %s, Slug: %s, Action: %s", category, slug, action) + + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + + logger.debug("Processing outputs from %d node(s)", len(outputs)) + for node_id, node_output in outputs.items(): + logger.debug(" Node %s: %s", node_id, list(node_output.keys())) + if 'images' in node_output: + logger.debug(" Found %d image(s) in node %s", len(node_output['images']), node_id) + image_info = node_output['images'][0] + logger.debug(" Image info: filename=%s, subfolder=%s, type=%s", + image_info['filename'], image_info['subfolder'], image_info['type']) + + image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) + + upload_folder = current_app.config['UPLOAD_FOLDER'] + folder = os.path.join(upload_folder, f"{category}/{slug}") + os.makedirs(folder, exist_ok=True) + filename = f"gen_{int(time.time())}.png" + full_path = os.path.join(folder, filename) + + logger.debug(" Saving image to: %s", full_path) + with open(full_path, 'wb') as f: + f.write(image_data) + logger.info("Image saved: %s (%d bytes)", full_path, len(image_data)) + + relative_path = f"{category}/{slug}/{filename}" + # Include the seed used for this generation + used_seed = job['workflow'].get('3', {}).get('inputs', {}).get('seed') + + job['result'] = { + 'image_url': f'/static/uploads/{relative_path}', + 'relative_path': relative_path, + 'seed': used_seed, + } + + if db_model_class and (action is None or action == 'replace'): + logger.debug(" Updating database: %s.image_path = %s", db_model_class.__name__, relative_path) + obj = db_model_class.query.filter_by(slug=slug).first() + if obj: + obj.image_path = relative_path + db.session.commit() + logger.debug(" Database updated successfully") + else: + logger.warning(" Object not found in database: %s(slug=%s)", db_model_class.__name__, slug) + else: + logger.debug(" Skipping database update (db_model_class=%s, action=%s)", + db_model_class.__name__ if db_model_class else None, action) + + logger.debug("FINALIZE - Completed successfully") + logger.debug("=" * 80) + return + + logger.warning("FINALIZE - No images found in outputs!") + logger.debug("=" * 80) + return _finalize + + +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 + with _job_queue_lock: + stale = [jid for jid, j in _job_history.items() + if j['status'] in ('done', 'failed', 'removed') and j['created_at'] < cutoff] + for jid in stale: + del _job_history[jid] + + +def init_queue_worker(flask_app): + """Store the Flask app reference and start the background worker thread. + + Called once from app.py during startup. + """ + global _app + _app = flask_app + worker = threading.Thread(target=_queue_worker, daemon=True, name='queue-worker') + worker.start() diff --git a/services/llm.py b/services/llm.py new file mode 100644 index 0000000..7ee8fba --- /dev/null +++ b/services/llm.py @@ -0,0 +1,203 @@ +import os +import json +import asyncio +import requests +from flask import request as flask_request +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from models import Settings + +DANBOORU_TOOLS = [ + { + "type": "function", + "function": { + "name": "search_tags", + "description": "Prefix/full-text search for Danbooru tags. Returns rich tag objects ordered by relevance.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search string. Trailing * added automatically."}, + "limit": {"type": "integer", "description": "Max results (1-200)", "default": 20}, + "category": {"type": "string", "enum": ["general", "artist", "copyright", "character", "meta"], "description": "Optional category filter."} + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "validate_tags", + "description": "Exact-match validation for a list of tags. Splits into valid, deprecated, and invalid.", + "parameters": { + "type": "object", + "properties": { + "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to validate."} + }, + "required": ["tags"] + } + } + }, + { + "type": "function", + "function": { + "name": "suggest_tags", + "description": "Autocomplete-style suggestions for a partial or approximate tag. Sorted by post count.", + "parameters": { + "type": "object", + "properties": { + "partial": {"type": "string", "description": "Partial tag or rough approximation."}, + "limit": {"type": "integer", "description": "Max suggestions (1-50)", "default": 10}, + "category": {"type": "string", "enum": ["general", "artist", "copyright", "character", "meta"], "description": "Optional category filter."} + }, + "required": ["partial"] + } + } + } +] + + +async def _run_mcp_tool(name, arguments): + server_params = StdioServerParameters( + command="docker", + args=["run", "--rm", "-i", "danbooru-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_mcp_tool(name, arguments): + try: + return asyncio.run(_run_mcp_tool(name, arguments)) + except Exception as e: + print(f"MCP Tool Error: {e}") + return json.dumps({"error": str(e)}) + + +def load_prompt(filename): + path = os.path.join('data/prompts', filename) + if os.path.exists(path): + with open(path, 'r') as f: + return f.read() + return None + + +def call_llm(prompt, system_prompt="You are a creative assistant."): + settings = Settings.query.first() + if not settings: + raise ValueError("Settings not configured.") + + is_local = settings.llm_provider != 'openrouter' + + if not is_local: + if not settings.openrouter_api_key: + raise ValueError("OpenRouter API Key not configured. Please configure it in Settings.") + + url = "https://openrouter.ai/api/v1/chat/completions" + headers = { + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + "HTTP-Referer": flask_request.url_root, + "X-Title": "Character Browser" + } + model = settings.openrouter_model or 'google/gemini-2.0-flash-001' + else: + # Local provider (Ollama or LMStudio) + if not settings.local_base_url: + raise ValueError(f"{settings.llm_provider.title()} Base URL not configured.") + + url = f"{settings.local_base_url.rstrip('/')}/chat/completions" + headers = {"Content-Type": "application/json"} + model = settings.local_model + if not model: + raise ValueError(f"No local model selected for {settings.llm_provider.title()}. Please select one in Settings.") + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt} + ] + + max_turns = 10 + use_tools = True + format_retries = 3 # retries allowed for unexpected response format + + while max_turns > 0: + max_turns -= 1 + data = { + "model": model, + "messages": messages, + } + + # Only add tools if supported/requested + if use_tools: + data["tools"] = DANBOORU_TOOLS + data["tool_choice"] = "auto" + + try: + response = requests.post(url, headers=headers, json=data) + + # If 400 Bad Request and we were using tools, try once without tools + if response.status_code == 400 and use_tools: + print(f"LLM Provider {settings.llm_provider} rejected tools. Retrying without tool calling...") + use_tools = False + max_turns += 1 # Reset turn for the retry + continue + + response.raise_for_status() + result = response.json() + + # Validate expected OpenAI-compatible response shape + if 'choices' not in result or not result['choices']: + raise KeyError('choices') + + message = result['choices'][0].get('message') + if message is None: + raise KeyError('message') + + if message.get('tool_calls'): + messages.append(message) + for tool_call in message['tool_calls']: + name = tool_call['function']['name'] + args = json.loads(tool_call['function']['arguments']) + print(f"Executing MCP tool: {name}({args})") + tool_result = call_mcp_tool(name, args) + messages.append({ + "role": "tool", + "tool_call_id": tool_call['id'], + "name": name, + "content": tool_result + }) + continue + + return message['content'] + except requests.exceptions.RequestException as e: + error_body = "" + try: error_body = f" - Body: {response.text}" + except: pass + raise RuntimeError(f"LLM API request failed: {str(e)}{error_body}") from e + except (KeyError, IndexError) as e: + # Log the raw response to help diagnose the issue + raw = "" + try: raw = response.text[:500] + except: pass + print(f"Unexpected LLM response format (key={e}). Raw response: {raw}") + if format_retries > 0: + format_retries -= 1 + max_turns += 1 # don't burn a turn on a format error + # Ask the model to try again with the correct format + messages.append({ + "role": "user", + "content": ( + "Your previous response was not in the expected format. " + "Please respond with valid JSON only, exactly as specified in the system prompt. " + "Do not include any explanation or markdown — only the raw JSON object." + ) + }) + print(f"Retrying after format error ({format_retries} retries left)…") + continue + raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e + + raise RuntimeError("LLM tool calling loop exceeded maximum turns") diff --git a/services/mcp.py b/services/mcp.py new file mode 100644 index 0000000..29d3164 --- /dev/null +++ b/services/mcp.py @@ -0,0 +1,155 @@ +import os +import subprocess + +# Path to the MCP docker-compose projects, relative to the main app file. +MCP_TOOLS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tools') +MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp') +MCP_REPO_URL = 'https://git.liveaodh.com/aodhan/danbooru-mcp' +CHAR_MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'character-mcp') +CHAR_MCP_REPO_URL = 'https://git.liveaodh.com/aodhan/character-mcp.git' + + +def _ensure_mcp_repo(): + """Clone or update the danbooru-mcp source repository inside tools/. + + - If ``tools/danbooru-mcp/`` does not exist, clone from MCP_REPO_URL. + - If it already exists, run ``git pull`` to fetch the latest changes. + Errors are non-fatal. + """ + os.makedirs(MCP_TOOLS_DIR, exist_ok=True) + try: + if not os.path.isdir(MCP_COMPOSE_DIR): + print(f'Cloning danbooru-mcp from {MCP_REPO_URL} …') + subprocess.run( + ['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR], + timeout=120, check=True, + ) + print('danbooru-mcp cloned successfully.') + else: + print('Updating danbooru-mcp via git pull …') + subprocess.run( + ['git', 'pull'], + cwd=MCP_COMPOSE_DIR, + timeout=60, check=True, + ) + print('danbooru-mcp updated.') + except FileNotFoundError: + print('WARNING: git not found on PATH — danbooru-mcp repo will not be cloned/updated.') + except subprocess.CalledProcessError as e: + print(f'WARNING: git operation failed for danbooru-mcp: {e}') + except subprocess.TimeoutExpired: + print('WARNING: git timed out while cloning/updating danbooru-mcp.') + except Exception as e: + print(f'WARNING: Could not clone/update danbooru-mcp repo: {e}') + + +def ensure_mcp_server_running(): + """Ensure the danbooru-mcp repo is present/up-to-date, then start the + Docker container if it is not already running. + + Uses ``docker compose up -d`` so the image is built automatically on first + run. Errors are non-fatal — the app will still start even if Docker is + unavailable. + + Skipped when ``SKIP_MCP_AUTOSTART=true`` (set by docker-compose, where the + danbooru-mcp service is managed by compose instead). + """ + if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true': + print('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.') + return + _ensure_mcp_repo() + try: + result = subprocess.run( + ['docker', 'ps', '--filter', 'name=danbooru-mcp', '--format', '{{.Names}}'], + capture_output=True, text=True, timeout=10, + ) + if 'danbooru-mcp' in result.stdout: + print('danbooru-mcp container already running.') + return + # Container not running — start it via docker compose + print('Starting danbooru-mcp container via docker compose …') + subprocess.run( + ['docker', 'compose', 'up', '-d'], + cwd=MCP_COMPOSE_DIR, + timeout=120, + ) + print('danbooru-mcp container started.') + except FileNotFoundError: + print('WARNING: docker not found on PATH — danbooru-mcp will not be started automatically.') + except subprocess.TimeoutExpired: + print('WARNING: docker timed out while starting danbooru-mcp.') + except Exception as e: + print(f'WARNING: Could not ensure danbooru-mcp is running: {e}') + + +def _ensure_character_mcp_repo(): + """Clone or update the character-mcp source repository inside tools/. + + - If ``tools/character-mcp/`` does not exist, clone from CHAR_MCP_REPO_URL. + - If it already exists, run ``git pull`` to fetch the latest changes. + Errors are non-fatal. + """ + os.makedirs(MCP_TOOLS_DIR, exist_ok=True) + try: + if not os.path.isdir(CHAR_MCP_COMPOSE_DIR): + print(f'Cloning character-mcp from {CHAR_MCP_REPO_URL} …') + subprocess.run( + ['git', 'clone', CHAR_MCP_REPO_URL, CHAR_MCP_COMPOSE_DIR], + timeout=120, check=True, + ) + print('character-mcp cloned successfully.') + else: + print('Updating character-mcp via git pull …') + subprocess.run( + ['git', 'pull'], + cwd=CHAR_MCP_COMPOSE_DIR, + timeout=60, check=True, + ) + print('character-mcp updated.') + except FileNotFoundError: + print('WARNING: git not found on PATH — character-mcp repo will not be cloned/updated.') + except subprocess.CalledProcessError as e: + print(f'WARNING: git operation failed for character-mcp: {e}') + except subprocess.TimeoutExpired: + print('WARNING: git timed out while cloning/updating character-mcp.') + except Exception as e: + print(f'WARNING: Could not clone/update character-mcp repo: {e}') + + +def ensure_character_mcp_server_running(): + """Ensure the character-mcp repo is present/up-to-date, then start the + Docker container if it is not already running. + + Uses ``docker compose up -d`` so the image is built automatically on first + run. Errors are non-fatal — the app will still start even if Docker is + unavailable. + + Skipped when ``SKIP_MCP_AUTOSTART=true`` (set by docker-compose, where the + character-mcp service is managed by compose instead). + """ + if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true': + print('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.') + return + _ensure_character_mcp_repo() + try: + result = subprocess.run( + ['docker', 'ps', '--filter', 'name=character-mcp', '--format', '{{.Names}}'], + capture_output=True, text=True, timeout=10, + ) + if 'character-mcp' in result.stdout: + print('character-mcp container already running.') + return + # Container not running — start it via docker compose + print('Starting character-mcp container via docker compose …') + subprocess.run( + ['docker', 'compose', 'up', '-d'], + cwd=CHAR_MCP_COMPOSE_DIR, + timeout=120, + ) + print('character-mcp container started.') + except FileNotFoundError: + print('WARNING: docker not found on PATH — character-mcp will not be started automatically.') + except subprocess.TimeoutExpired: + print('WARNING: docker timed out while starting character-mcp.') + except Exception as e: + print(f'WARNING: Could not ensure character-mcp is running: {e}') diff --git a/services/prompts.py b/services/prompts.py new file mode 100644 index 0000000..894339b --- /dev/null +++ b/services/prompts.py @@ -0,0 +1,274 @@ +import re +from models import db, Character +from utils import _IDENTITY_KEYS, _WARDROBE_KEYS, parse_orientation + + +def _dedup_tags(prompt_str): + """Remove duplicate tags from a comma-separated prompt string, preserving first-occurrence order.""" + seen = set() + result = [] + for tag in prompt_str.split(','): + t = tag.strip() + if t and t.lower() not in seen: + seen.add(t.lower()) + result.append(t) + return ', '.join(result) + + +def _cross_dedup_prompts(positive, negative): + """Remove tags shared between positive and negative prompts. + + Repeatedly strips the first occurrence from each side until the tag exists + on only one side. Equal counts cancel out completely; any excess on one side + retains the remainder, allowing deliberate overrides (e.g. adding a tag twice + in the positive while it appears once in the negative leaves one copy positive). + """ + def parse_tags(s): + return [t.strip() for t in s.split(',') if t.strip()] + + pos_tags = parse_tags(positive) + neg_tags = parse_tags(negative) + + shared = {t.lower() for t in pos_tags} & {t.lower() for t in neg_tags} + for tag_lower in shared: + while ( + any(t.lower() == tag_lower for t in pos_tags) and + any(t.lower() == tag_lower for t in neg_tags) + ): + pos_tags.pop(next(i for i, t in enumerate(pos_tags) if t.lower() == tag_lower)) + neg_tags.pop(next(i for i, t in enumerate(neg_tags) if t.lower() == tag_lower)) + + return ', '.join(pos_tags), ', '.join(neg_tags) + + +def _resolve_character(character_slug): + """Resolve a character_slug string (possibly '__random__') to a Character instance.""" + if character_slug == '__random__': + return Character.query.order_by(db.func.random()).first() + if character_slug: + return Character.query.filter_by(slug=character_slug).first() + return None + + +def _ensure_character_fields(character, selected_fields, include_wardrobe=True, include_defaults=False): + """Mutate selected_fields in place to include essential character identity/wardrobe/name keys. + + include_wardrobe — also inject active wardrobe keys (default True) + include_defaults — also inject defaults::expression and defaults::pose (for outfit/look previews) + """ + identity = character.data.get('identity', {}) + for key in _IDENTITY_KEYS: + if identity.get(key): + field_key = f'identity::{key}' + if field_key not in selected_fields: + selected_fields.append(field_key) + if include_defaults: + for key in ['expression', 'pose']: + if character.data.get('defaults', {}).get(key): + field_key = f'defaults::{key}' + if field_key not in selected_fields: + selected_fields.append(field_key) + if 'special::name' not in selected_fields: + selected_fields.append('special::name') + if include_wardrobe: + wardrobe = character.get_active_wardrobe() + for key in _WARDROBE_KEYS: + if wardrobe.get(key): + field_key = f'wardrobe::{key}' + if field_key not in selected_fields: + selected_fields.append(field_key) + + +def _append_background(prompts, character=None): + """Append a (color-prefixed) simple background tag to prompts['main'].""" + primary_color = character.data.get('styles', {}).get('primary_color', '') if character else '' + bg = f"{primary_color} simple background" if primary_color else "simple background" + prompts['main'] = f"{prompts['main']}, {bg}" + + +def build_prompt(data, selected_fields=None, default_fields=None, active_outfit='default'): + def is_selected(section, key): + # Priority: + # 1. Manual selection from form (if list is not empty) + # 2. Default fields (saved per character) + # 3. Select all (fallback) + if selected_fields is not None and len(selected_fields) > 0: + return f"{section}::{key}" in selected_fields + if default_fields: + return f"{section}::{key}" in default_fields + return True + + identity = data.get('identity', {}) + + # Get wardrobe - handle both new nested format and legacy flat format + wardrobe_data = data.get('wardrobe', {}) + if 'default' in wardrobe_data and isinstance(wardrobe_data.get('default'), dict): + # New nested format - get active outfit + wardrobe = wardrobe_data.get(active_outfit or 'default', wardrobe_data.get('default', {})) + else: + # Legacy flat format + wardrobe = wardrobe_data + + defaults = data.get('defaults', {}) + action_data = data.get('action', {}) + style_data = data.get('style', {}) + participants = data.get('participants', {}) + + # Pre-calculate Hand/Glove priority + # Priority: wardrobe gloves > wardrobe hands (outfit) > identity hands (character) + hand_val = "" + if wardrobe.get('gloves') and is_selected('wardrobe', 'gloves'): + hand_val = wardrobe.get('gloves') + elif wardrobe.get('hands') and is_selected('wardrobe', 'hands'): + hand_val = wardrobe.get('hands') + elif identity.get('hands') and is_selected('identity', 'hands'): + hand_val = identity.get('hands') + + # 1. Main Prompt + parts = [] + + # Handle participants logic + if participants: + if participants.get('solo_focus') == 'true': + parts.append('(solo focus:1.2)') + + orientation = participants.get('orientation', '') + if orientation: + parts.extend(parse_orientation(orientation)) + else: + # Default behavior + parts.append("(solo:1.2)") + + # Use character_id (underscores to spaces) for tags compatibility + char_tag = data.get('character_id', '').replace('_', ' ') + if char_tag and is_selected('special', 'name'): + parts.append(char_tag) + + for key in ['base_specs', 'hair', 'eyes', 'extra']: + val = identity.get(key) + if val and is_selected('identity', key): + # Filter out conflicting tags if participants data is present + if participants and key == 'base_specs': + # Remove 1girl, 1boy, solo, etc. + val = re.sub(r'\b(1girl|1boy|solo)\b', '', val).replace(', ,', ',').strip(', ') + parts.append(val) + + # Add defaults (expression, pose, scene) + for key in ['expression', 'pose', 'scene']: + val = defaults.get(key) + if val and is_selected('defaults', key): + parts.append(val) + + # Add hand priority value to main prompt + if hand_val: + parts.append(hand_val) + + for key in ['full_body', 'top', 'bottom', 'headwear', 'legwear', 'footwear', 'accessories']: + val = wardrobe.get(key) + if val and is_selected('wardrobe', key): + parts.append(val) + + # Standard character styles + char_aesthetic = data.get('styles', {}).get('aesthetic') + if char_aesthetic and is_selected('styles', 'aesthetic'): + parts.append(f"{char_aesthetic} style") + + # New Styles Gallery logic + if style_data.get('artist_name') and is_selected('style', 'artist_name'): + parts.append(f"by {style_data['artist_name']}") + 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')) + + # 2. Face Prompt: Tag, Eyes, Expression, Headwear, Action details + face_parts = [] + if char_tag and is_selected('special', 'name'): face_parts.append(char_tag) + if identity.get('eyes') and is_selected('identity', 'eyes'): face_parts.append(identity.get('eyes')) + if defaults.get('expression') and is_selected('defaults', 'expression'): face_parts.append(defaults.get('expression')) + if wardrobe.get('headwear') and is_selected('wardrobe', 'headwear'): face_parts.append(wardrobe.get('headwear')) + + # Add specific Action expression details if available + if action_data.get('head') and is_selected('action', 'head'): face_parts.append(action_data.get('head')) + if action_data.get('eyes') and is_selected('action', 'eyes'): face_parts.append(action_data.get('eyes')) + + # 3. Hand Prompt: Hand value (Gloves or Hands), Action details + hand_parts = [hand_val] if hand_val else [] + if action_data.get('arms') and is_selected('action', 'arms'): hand_parts.append(action_data.get('arms')) + if action_data.get('hands') and is_selected('action', 'hands'): hand_parts.append(action_data.get('hands')) + + return { + "main": _dedup_tags(", ".join(parts)), + "face": _dedup_tags(", ".join(face_parts)), + "hand": _dedup_tags(", ".join(hand_parts)) + } + + +def build_extras_prompt(actions, outfits, scenes, styles, detailers): + """Combine positive prompt text from all selected category items.""" + parts = [] + + for action in actions: + data = action.data + lora = data.get('lora', {}) + if lora.get('lora_triggers'): + parts.append(lora['lora_triggers']) + parts.extend(data.get('tags', [])) + for key in ['full_body', 'additional']: + val = data.get('action', {}).get(key) + if val: + parts.append(val) + + for outfit in outfits: + data = outfit.data + wardrobe = data.get('wardrobe', {}) + for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'accessories']: + val = wardrobe.get(key) + if val: + parts.append(val) + 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 + scene_fields = data.get('scene', {}) + for key in ['background', 'foreground', 'lighting']: + val = scene_fields.get(key) + if val: + parts.append(val) + 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 + style_fields = data.get('style', {}) + if style_fields.get('artist_name'): + parts.append(f"by {style_fields['artist_name']}") + if style_fields.get('artistic_style'): + parts.append(style_fields['artistic_style']) + lora = data.get('lora', {}) + if lora.get('lora_triggers'): + parts.append(lora['lora_triggers']) + + for detailer in detailers: + data = detailer.data + prompt = data.get('prompt', '') + if isinstance(prompt, list): + parts.extend(p for p in prompt if p) + elif prompt: + parts.append(prompt) + lora = data.get('lora', {}) + if lora.get('lora_triggers'): + parts.append(lora['lora_triggers']) + + return ", ".join(p for p in parts if p) diff --git a/services/sync.py b/services/sync.py new file mode 100644 index 0000000..7b1df7a --- /dev/null +++ b/services/sync.py @@ -0,0 +1,701 @@ +import os +import json +import re +import random +import logging + +from flask import current_app +from sqlalchemy.orm.attributes import flag_modified + +from models import ( + db, Character, Look, Outfit, Action, Style, Scene, Detailer, Checkpoint, Preset +) + +logger = logging.getLogger('gaze') + + +def sync_characters(): + if not os.path.exists(current_app.config['CHARACTERS_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['CHARACTERS_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['CHARACTERS_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + char_id = data.get('character_id') + if not char_id: + continue + + current_ids.append(char_id) + + # Generate URL-safe slug: remove special characters from character_id + slug = re.sub(r'[^a-zA-Z0-9_]', '', char_id) + + # Check if character already exists + character = Character.query.filter_by(character_id=char_id).first() + name = data.get('character_name', char_id.replace('_', ' ').title()) + + if character: + character.data = data + character.name = name + character.slug = slug + character.filename = filename + + # Check if cover image still exists + if character.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], character.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {character.name}, clearing path.") + character.image_path = None + + # Explicitly tell SQLAlchemy the JSON field was modified + flag_modified(character, "data") + else: + new_char = Character( + character_id=char_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_char) + except Exception as e: + print(f"Error importing {filename}: {e}") + + # Remove characters that are no longer in the folder + all_characters = Character.query.all() + for char in all_characters: + if char.character_id not in current_ids: + db.session.delete(char) + + db.session.commit() + +def sync_outfits(): + if not os.path.exists(current_app.config['CLOTHING_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['CLOTHING_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['CLOTHING_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + outfit_id = data.get('outfit_id') or filename.replace('.json', '') + + current_ids.append(outfit_id) + + # Generate URL-safe slug: remove special characters from outfit_id + slug = re.sub(r'[^a-zA-Z0-9_]', '', outfit_id) + + # Check if outfit already exists + outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + name = data.get('outfit_name', outfit_id.replace('_', ' ').title()) + + if outfit: + outfit.data = data + outfit.name = name + outfit.slug = slug + outfit.filename = filename + + # Check if cover image still exists + if outfit.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], outfit.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {outfit.name}, clearing path.") + outfit.image_path = None + + # Explicitly tell SQLAlchemy the JSON field was modified + flag_modified(outfit, "data") + else: + new_outfit = Outfit( + outfit_id=outfit_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_outfit) + except Exception as e: + print(f"Error importing outfit {filename}: {e}") + + # Remove outfits that are no longer in the folder + all_outfits = Outfit.query.all() + for outfit in all_outfits: + if outfit.outfit_id not in current_ids: + db.session.delete(outfit) + + db.session.commit() + +def ensure_default_outfit(): + """Ensure a default outfit file exists and is registered in the database. + + Checks if data/clothing/default.json exists, creates it with a minimal + wardrobe structure if missing, and ensures a corresponding Outfit database + entry exists. + """ + default_outfit_path = os.path.join(current_app.config['CLOTHING_DIR'], 'default.json') + + # Check if default outfit file exists + if not os.path.exists(default_outfit_path): + logger.info("Default outfit file not found at %s, creating it...", default_outfit_path) + + # Ensure the clothing directory exists + os.makedirs(current_app.config['CLOTHING_DIR'], exist_ok=True) + + # Create minimal default outfit structure + default_outfit_data = { + "outfit_id": "default", + "outfit_name": "Default", + "wardrobe": { + "full_body": "", + "headwear": "", + "top": "", + "bottom": "", + "legwear": "", + "footwear": "", + "hands": "", + "accessories": "" + }, + "lora": { + "lora_name": "", + "lora_weight": 0.8, + "lora_triggers": "" + }, + "tags": [] + } + + try: + # Write the default outfit file + with open(default_outfit_path, 'w') as f: + json.dump(default_outfit_data, f, indent=2) + logger.info("Created default outfit file at %s", default_outfit_path) + except Exception as e: + logger.error("Failed to create default outfit file: %s", e) + return False + + # Check if Outfit database entry exists + outfit = Outfit.query.filter_by(outfit_id='default').first() + if not outfit: + logger.info("Default Outfit database entry not found, creating it...") + + # Load the outfit data (either existing or newly created) + try: + with open(default_outfit_path, 'r') as f: + outfit_data = json.load(f) + except Exception as e: + logger.error("Failed to read default outfit file: %s", e) + return False + + # Create database entry + try: + new_outfit = Outfit( + outfit_id='default', + slug='default', + filename='default.json', + name='Default', + data=outfit_data + ) + db.session.add(new_outfit) + db.session.commit() + logger.info("Created default Outfit database entry") + except Exception as e: + logger.error("Failed to create default Outfit database entry: %s", e) + db.session.rollback() + return False + else: + logger.debug("Default Outfit database entry already exists") + + logger.info("Default outfit verification complete") + return True + + + +def sync_looks(): + if not os.path.exists(current_app.config['LOOKS_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['LOOKS_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['LOOKS_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + look_id = data.get('look_id') or filename.replace('.json', '') + + current_ids.append(look_id) + + slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id) + + look = Look.query.filter_by(look_id=look_id).first() + name = data.get('look_name', look_id.replace('_', ' ').title()) + character_id = data.get('character_id', None) + + if look: + look.data = data + look.name = name + look.slug = slug + look.filename = filename + look.character_id = character_id + + if look.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path) + if not os.path.exists(full_img_path): + look.image_path = None + + flag_modified(look, "data") + else: + new_look = Look( + look_id=look_id, + slug=slug, + filename=filename, + name=name, + character_id=character_id, + data=data + ) + db.session.add(new_look) + except Exception as e: + print(f"Error importing look {filename}: {e}") + + all_looks = Look.query.all() + for look in all_looks: + if look.look_id not in current_ids: + db.session.delete(look) + + db.session.commit() + +def sync_presets(): + if not os.path.exists(current_app.config['PRESETS_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['PRESETS_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['PRESETS_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + preset_id = data.get('preset_id') or filename.replace('.json', '') + + current_ids.append(preset_id) + + slug = re.sub(r'[^a-zA-Z0-9_]', '', preset_id) + + preset = Preset.query.filter_by(preset_id=preset_id).first() + name = data.get('preset_name', preset_id.replace('_', ' ').title()) + + if preset: + preset.data = data + preset.name = name + preset.slug = slug + preset.filename = filename + + if preset.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], preset.image_path) + if not os.path.exists(full_img_path): + preset.image_path = None + + flag_modified(preset, "data") + else: + new_preset = Preset( + preset_id=preset_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_preset) + except Exception as e: + print(f"Error importing preset {filename}: {e}") + + all_presets = Preset.query.all() + for preset in all_presets: + if preset.preset_id not in current_ids: + db.session.delete(preset) + + db.session.commit() + + +# --------------------------------------------------------------------------- +# Preset helpers +# --------------------------------------------------------------------------- + +_PRESET_ENTITY_MAP = { + 'character': (Character, 'character_id'), + 'outfit': (Outfit, 'outfit_id'), + 'action': (Action, 'action_id'), + 'style': (Style, 'style_id'), + 'scene': (Scene, 'scene_id'), + 'detailer': (Detailer, 'detailer_id'), + 'look': (Look, 'look_id'), + 'checkpoint': (Checkpoint, 'checkpoint_path'), +} + + +def _resolve_preset_entity(entity_type, entity_id): + """Resolve a preset entity_id ('random', specific ID, or None) to an ORM object.""" + if not entity_id: + return None + model_class, id_field = _PRESET_ENTITY_MAP[entity_type] + if entity_id == 'random': + return model_class.query.order_by(db.func.random()).first() + return model_class.query.filter(getattr(model_class, id_field) == entity_id).first() + + +def _resolve_preset_fields(preset_data): + """Convert preset field toggle dicts into a selected_fields list. + + Each field value: True = include, False = exclude, 'random' = randomly decide. + Returns a list of 'section::key' strings for fields that are active. + """ + selected = [] + char_cfg = preset_data.get('character', {}) + fields = char_cfg.get('fields', {}) + + for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: + val = fields.get('identity', {}).get(key, True) + if val == 'random': + val = random.choice([True, False]) + if val: + selected.append(f'identity::{key}') + + for key in ['expression', 'pose', 'scene']: + val = fields.get('defaults', {}).get(key, False) + if val == 'random': + val = random.choice([True, False]) + if val: + selected.append(f'defaults::{key}') + + wardrobe_cfg = fields.get('wardrobe', {}) + for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + val = wardrobe_cfg.get('fields', {}).get(key, True) + if val == 'random': + val = random.choice([True, False]) + if val: + selected.append(f'wardrobe::{key}') + + # Always include name and lora triggers + selected.append('special::name') + if char_cfg.get('use_lora', True): + selected.append('lora::lora_triggers') + + return selected + + +def sync_actions(): + if not os.path.exists(current_app.config['ACTIONS_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['ACTIONS_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['ACTIONS_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + action_id = data.get('action_id') or filename.replace('.json', '') + + current_ids.append(action_id) + + # Generate URL-safe slug + slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id) + + # Check if action already exists + action = Action.query.filter_by(action_id=action_id).first() + name = data.get('action_name', action_id.replace('_', ' ').title()) + + if action: + action.data = data + action.name = name + action.slug = slug + action.filename = filename + + # Check if cover image still exists + if action.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], action.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {action.name}, clearing path.") + action.image_path = None + + flag_modified(action, "data") + else: + new_action = Action( + action_id=action_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_action) + except Exception as e: + print(f"Error importing action {filename}: {e}") + + # Remove actions that are no longer in the folder + all_actions = Action.query.all() + for action in all_actions: + if action.action_id not in current_ids: + db.session.delete(action) + + db.session.commit() + +def sync_styles(): + if not os.path.exists(current_app.config['STYLES_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['STYLES_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['STYLES_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + style_id = data.get('style_id') or filename.replace('.json', '') + + current_ids.append(style_id) + + # Generate URL-safe slug + slug = re.sub(r'[^a-zA-Z0-9_]', '', style_id) + + # Check if style already exists + style = Style.query.filter_by(style_id=style_id).first() + name = data.get('style_name', style_id.replace('_', ' ').title()) + + if style: + style.data = data + style.name = name + style.slug = slug + style.filename = filename + + # Check if cover image still exists + if style.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], style.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {style.name}, clearing path.") + style.image_path = None + + flag_modified(style, "data") + else: + new_style = Style( + style_id=style_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_style) + except Exception as e: + print(f"Error importing style {filename}: {e}") + + # Remove styles that are no longer in the folder + all_styles = Style.query.all() + for style in all_styles: + if style.style_id not in current_ids: + db.session.delete(style) + + db.session.commit() + +def sync_detailers(): + if not os.path.exists(current_app.config['DETAILERS_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['DETAILERS_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['DETAILERS_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + detailer_id = data.get('detailer_id') or filename.replace('.json', '') + + current_ids.append(detailer_id) + + # Generate URL-safe slug + slug = re.sub(r'[^a-zA-Z0-9_]', '', detailer_id) + + # Check if detailer already exists + detailer = Detailer.query.filter_by(detailer_id=detailer_id).first() + name = data.get('detailer_name', detailer_id.replace('_', ' ').title()) + + if detailer: + detailer.data = data + detailer.name = name + detailer.slug = slug + detailer.filename = filename + + # Check if cover image still exists + if detailer.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], detailer.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {detailer.name}, clearing path.") + detailer.image_path = None + + flag_modified(detailer, "data") + else: + new_detailer = Detailer( + detailer_id=detailer_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_detailer) + except Exception as e: + print(f"Error importing detailer {filename}: {e}") + + # Remove detailers that are no longer in the folder + all_detailers = Detailer.query.all() + for detailer in all_detailers: + if detailer.detailer_id not in current_ids: + db.session.delete(detailer) + + db.session.commit() + +def sync_scenes(): + if not os.path.exists(current_app.config['SCENES_DIR']): + return + + current_ids = [] + + for filename in os.listdir(current_app.config['SCENES_DIR']): + if filename.endswith('.json'): + file_path = os.path.join(current_app.config['SCENES_DIR'], filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + scene_id = data.get('scene_id') or filename.replace('.json', '') + + current_ids.append(scene_id) + + # Generate URL-safe slug + slug = re.sub(r'[^a-zA-Z0-9_]', '', scene_id) + + # Check if scene already exists + scene = Scene.query.filter_by(scene_id=scene_id).first() + name = data.get('scene_name', scene_id.replace('_', ' ').title()) + + if scene: + scene.data = data + scene.name = name + scene.slug = slug + scene.filename = filename + + # Check if cover image still exists + if scene.image_path: + full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], scene.image_path) + if not os.path.exists(full_img_path): + print(f"Image missing for {scene.name}, clearing path.") + scene.image_path = None + + flag_modified(scene, "data") + else: + new_scene = Scene( + scene_id=scene_id, + slug=slug, + filename=filename, + name=name, + data=data + ) + db.session.add(new_scene) + except Exception as e: + print(f"Error importing scene {filename}: {e}") + + # Remove scenes that are no longer in the folder + all_scenes = Scene.query.all() + for scene in all_scenes: + if scene.scene_id not in current_ids: + db.session.delete(scene) + + db.session.commit() + +def _default_checkpoint_data(checkpoint_path, filename): + """Return template-default data for a checkpoint with no JSON file.""" + name_base = filename.rsplit('.', 1)[0] + return { + "checkpoint_path": checkpoint_path, + "checkpoint_name": filename, + "base_positive": "anime", + "base_negative": "text, logo", + "steps": 25, + "cfg": 5, + "sampler_name": "euler_ancestral", + "vae": "integrated" + } + +def sync_checkpoints(): + checkpoints_dir = current_app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') + os.makedirs(checkpoints_dir, exist_ok=True) + + # Load all JSON data files keyed by checkpoint_path + json_data_by_path = {} + for filename in os.listdir(checkpoints_dir): + if filename.endswith('.json') and not filename.endswith('.template'): + file_path = os.path.join(checkpoints_dir, filename) + try: + with open(file_path, 'r') as f: + data = json.load(f) + ckpt_path = data.get('checkpoint_path') + if ckpt_path: + json_data_by_path[ckpt_path] = data + except Exception as e: + print(f"Error reading checkpoint JSON {filename}: {e}") + + current_ids = [] + dirs = [ + (current_app.config.get('ILLUSTRIOUS_MODELS_DIR', ''), 'Illustrious'), + (current_app.config.get('NOOB_MODELS_DIR', ''), 'Noob'), + ] + for dirpath, family in dirs: + if not dirpath or not os.path.exists(dirpath): + continue + for f in sorted(os.listdir(dirpath)): + if not (f.endswith('.safetensors') or f.endswith('.ckpt')): + continue + checkpoint_path = f"{family}/{f}" + checkpoint_id = checkpoint_path + slug = re.sub(r'[^a-zA-Z0-9_]', '_', checkpoint_path.rsplit('.', 1)[0]).lower().strip('_') + name_base = f.rsplit('.', 1)[0] + friendly_name = re.sub(r'[^a-zA-Z0-9]+', ' ', name_base).strip().title() + current_ids.append(checkpoint_id) + + data = json_data_by_path.get(checkpoint_path, + _default_checkpoint_data(checkpoint_path, f)) + display_name = data.get('checkpoint_name', f).rsplit('.', 1)[0] + display_name = re.sub(r'[^a-zA-Z0-9]+', ' ', display_name).strip().title() or friendly_name + + ckpt = Checkpoint.query.filter_by(checkpoint_id=checkpoint_id).first() + if ckpt: + ckpt.name = display_name + ckpt.slug = slug + ckpt.checkpoint_path = checkpoint_path + ckpt.data = 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( + checkpoint_id=checkpoint_id, + slug=slug, + name=display_name, + checkpoint_path=checkpoint_path, + data=data, + )) + + all_ckpts = Checkpoint.query.all() + for ckpt in all_ckpts: + if ckpt.checkpoint_id not in current_ids: + db.session.delete(ckpt) + + db.session.commit() diff --git a/services/workflow.py b/services/workflow.py new file mode 100644 index 0000000..c6ea173 --- /dev/null +++ b/services/workflow.py @@ -0,0 +1,342 @@ +import json +import logging +import random + +from flask import session +from models import Settings, Checkpoint +from utils import _resolve_lora_weight +from services.prompts import _cross_dedup_prompts + +logger = logging.getLogger('gaze') + + +def _log_workflow_prompts(label, workflow): + """Log the final assembled ComfyUI prompts in a consistent, readable block.""" + sep = "=" * 72 + active_loras = [] + lora_details = [] + + # Collect detailed LoRA information + for node_id, label_str in [("16", "char/look"), ("17", "outfit"), ("18", "action"), ("19", "style/detail/scene")]: + if node_id in workflow: + name = workflow[node_id]["inputs"].get("lora_name", "") + if name: + strength_model = workflow[node_id]["inputs"].get("strength_model", "?") + strength_clip = workflow[node_id]["inputs"].get("strength_clip", "?") + + # Short version for summary + if isinstance(strength_model, float): + active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model:.3f}") + else: + active_loras.append(f"{label_str}:{name.split('/')[-1]}@{strength_model}") + + # Detailed version + lora_details.append(f" Node {node_id} ({label_str}): {name}") + lora_details.append(f" strength_model={strength_model}, strength_clip={strength_clip}") + + # Extract VAE information + vae_info = "(integrated)" + if '21' in workflow: + vae_info = workflow['21']['inputs'].get('vae_name', '(custom)') + + # Extract adetailer information + adetailer_info = [] + for node_id, node_name in [("11", "Face"), ("13", "Hand")]: + if node_id in workflow: + adetailer_info.append(f" {node_name} (Node {node_id}): steps={workflow[node_id]['inputs'].get('steps', '?')}, " + f"cfg={workflow[node_id]['inputs'].get('cfg', '?')}, " + f"denoise={workflow[node_id]['inputs'].get('denoise', '?')}") + + face_text = workflow.get('14', {}).get('inputs', {}).get('text', '') + hand_text = workflow.get('15', {}).get('inputs', {}).get('text', '') + + lines = [ + sep, + f" WORKFLOW PROMPTS [{label}]", + sep, + " MODEL CONFIGURATION:", + f" Checkpoint : {workflow['4']['inputs'].get('ckpt_name', '(not set)')}", + f" VAE : {vae_info}", + "", + " GENERATION SETTINGS:", + f" Seed : {workflow['3']['inputs'].get('seed', '(not set)')}", + f" Resolution : {workflow['5']['inputs'].get('width', '?')} x {workflow['5']['inputs'].get('height', '?')}", + f" Sampler : {workflow['3']['inputs'].get('sampler_name', '?')} / {workflow['3']['inputs'].get('scheduler', '?')}", + f" Steps : {workflow['3']['inputs'].get('steps', '?')}", + f" CFG Scale : {workflow['3']['inputs'].get('cfg', '?')}", + f" Denoise : {workflow['3']['inputs'].get('denoise', '1.0')}", + ] + + # Add LoRA details + if active_loras: + lines.append("") + lines.append(" LORA CONFIGURATION:") + lines.extend(lora_details) + else: + lines.append("") + lines.append(" LORA CONFIGURATION: (none)") + + # Add adetailer details + if adetailer_info: + lines.append("") + lines.append(" ADETAILER CONFIGURATION:") + lines.extend(adetailer_info) + + # Add prompts + lines.extend([ + "", + " PROMPTS:", + f" [+] Positive : {workflow['6']['inputs'].get('text', '')}", + f" [-] Negative : {workflow['7']['inputs'].get('text', '')}", + ]) + + if face_text: + lines.append(f" [F] Face : {face_text}") + if hand_text: + lines.append(f" [H] Hand : {hand_text}") + + lines.append(sep) + logger.info("\n%s", "\n".join(lines)) + + +def _apply_checkpoint_settings(workflow, ckpt_data): + """Apply checkpoint-specific sampler/prompt/VAE settings to the workflow.""" + steps = ckpt_data.get('steps') + cfg = ckpt_data.get('cfg') + sampler_name = ckpt_data.get('sampler_name') + scheduler = ckpt_data.get('scheduler') + base_positive = ckpt_data.get('base_positive', '') + base_negative = ckpt_data.get('base_negative', '') + vae = ckpt_data.get('vae', 'integrated') + + # KSampler (node 3) + if steps and '3' in workflow: + workflow['3']['inputs']['steps'] = int(steps) + if cfg and '3' in workflow: + workflow['3']['inputs']['cfg'] = float(cfg) + if sampler_name and '3' in workflow: + workflow['3']['inputs']['sampler_name'] = sampler_name + if scheduler and '3' in workflow: + workflow['3']['inputs']['scheduler'] = scheduler + + # Face/hand detailers (nodes 11, 13) + for node_id in ['11', '13']: + if node_id in workflow: + if steps: + workflow[node_id]['inputs']['steps'] = int(steps) + if cfg: + workflow[node_id]['inputs']['cfg'] = float(cfg) + if sampler_name: + workflow[node_id]['inputs']['sampler_name'] = sampler_name + if scheduler: + workflow[node_id]['inputs']['scheduler'] = scheduler + + # Prepend base_positive to positive prompts (main + face/hand detailers) + if base_positive: + for node_id in ['6', '14', '15']: + if node_id in workflow: + workflow[node_id]['inputs']['text'] = f"{base_positive}, {workflow[node_id]['inputs']['text']}" + + # Append base_negative to negative prompt (shared by main + detailers via node 7) + if base_negative and '7' in workflow: + workflow['7']['inputs']['text'] = f"{workflow['7']['inputs']['text']}, {base_negative}" + + # VAE: if not integrated, inject a VAELoader node and rewire + if vae and vae != 'integrated': + workflow['21'] = { + 'inputs': {'vae_name': vae}, + 'class_type': 'VAELoader' + } + if '8' in workflow: + workflow['8']['inputs']['vae'] = ['21', 0] + for node_id in ['11', '13']: + if node_id in workflow: + workflow[node_id]['inputs']['vae'] = ['21', 0] + + return workflow + + +def _get_default_checkpoint(): + """Return (checkpoint_path, checkpoint_data) from the database Settings, session, or fall back to workflow file.""" + ckpt_path = session.get('default_checkpoint') + + # If no session checkpoint, try to read from database Settings + if not ckpt_path: + settings = Settings.query.first() + if settings and settings.default_checkpoint: + ckpt_path = settings.default_checkpoint + logger.debug("Loaded default checkpoint from database: %s", ckpt_path) + + # If still no checkpoint, try to read from the workflow file + if not ckpt_path: + try: + with open('comfy_workflow.json', 'r') as f: + workflow = json.load(f) + ckpt_path = workflow.get('4', {}).get('inputs', {}).get('ckpt_name') + logger.debug("Loaded default checkpoint from workflow file: %s", ckpt_path) + except Exception: + pass + + if not ckpt_path: + return None, None + + ckpt = Checkpoint.query.filter_by(checkpoint_path=ckpt_path).first() + if not ckpt: + # Checkpoint path exists but not in DB - return path with empty data + return ckpt_path, {} + return ckpt.checkpoint_path, ckpt.data or {} + + +def _prepare_workflow(workflow, character, prompts, checkpoint=None, custom_negative=None, outfit=None, action=None, style=None, detailer=None, scene=None, width=None, height=None, checkpoint_data=None, look=None, fixed_seed=None): + # 1. Update prompts using replacement to preserve embeddings + workflow["6"]["inputs"]["text"] = workflow["6"]["inputs"]["text"].replace("{{POSITIVE_PROMPT}}", prompts["main"]) + + if custom_negative: + workflow["7"]["inputs"]["text"] = f"{workflow['7']['inputs']['text']}, {custom_negative}" + + if "14" in workflow: + workflow["14"]["inputs"]["text"] = workflow["14"]["inputs"]["text"].replace("{{FACE_PROMPT}}", prompts["face"]) + if "15" in workflow: + workflow["15"]["inputs"]["text"] = workflow["15"]["inputs"]["text"].replace("{{HAND_PROMPT}}", prompts["hand"]) + + # 2. Update Checkpoint - always set one, fall back to default if not provided + if not checkpoint: + default_ckpt, default_ckpt_data = _get_default_checkpoint() + checkpoint = default_ckpt + if not checkpoint_data: + checkpoint_data = default_ckpt_data + if checkpoint: + workflow["4"]["inputs"]["ckpt_name"] = checkpoint + else: + raise ValueError("No checkpoint specified and no default checkpoint configured") + + # 3. Handle LoRAs - Node 16 for character, Node 17 for outfit, Node 18 for action, Node 19 for style/detailer + # Start with direct checkpoint connections + model_source = ["4", 0] + clip_source = ["4", 1] + + # Look negative prompt (applied before character LoRA) + if look: + look_negative = look.data.get('negative', '') + if look_negative: + workflow["7"]["inputs"]["text"] = f"{look_negative}, {workflow['7']['inputs']['text']}" + + # Character LoRA (Node 16) — look LoRA overrides character LoRA when present + if look: + char_lora_data = look.data.get('lora', {}) + else: + char_lora_data = character.data.get('lora', {}) if character else {} + char_lora_name = char_lora_data.get('lora_name') + + if char_lora_name and "16" in workflow: + _w16 = _resolve_lora_weight(char_lora_data) + workflow["16"]["inputs"]["lora_name"] = char_lora_name + workflow["16"]["inputs"]["strength_model"] = _w16 + workflow["16"]["inputs"]["strength_clip"] = _w16 + workflow["16"]["inputs"]["model"] = ["4", 0] # From checkpoint + workflow["16"]["inputs"]["clip"] = ["4", 1] # From checkpoint + model_source = ["16", 0] + clip_source = ["16", 1] + logger.debug("Character LoRA: %s @ %s", char_lora_name, _w16) + + # Outfit LoRA (Node 17) - chains from character LoRA or checkpoint + outfit_lora_data = outfit.data.get('lora', {}) if outfit else {} + outfit_lora_name = outfit_lora_data.get('lora_name') + + if outfit_lora_name and "17" in workflow: + _w17 = _resolve_lora_weight({**{'lora_weight': 0.8}, **outfit_lora_data}) + workflow["17"]["inputs"]["lora_name"] = outfit_lora_name + workflow["17"]["inputs"]["strength_model"] = _w17 + workflow["17"]["inputs"]["strength_clip"] = _w17 + # Chain from character LoRA (node 16) or checkpoint (node 4) + workflow["17"]["inputs"]["model"] = model_source + workflow["17"]["inputs"]["clip"] = clip_source + model_source = ["17", 0] + clip_source = ["17", 1] + logger.debug("Outfit LoRA: %s @ %s", outfit_lora_name, _w17) + + # Action LoRA (Node 18) - chains from previous LoRA or checkpoint + action_lora_data = action.data.get('lora', {}) if action else {} + action_lora_name = action_lora_data.get('lora_name') + + if action_lora_name and "18" in workflow: + _w18 = _resolve_lora_weight(action_lora_data) + workflow["18"]["inputs"]["lora_name"] = action_lora_name + workflow["18"]["inputs"]["strength_model"] = _w18 + workflow["18"]["inputs"]["strength_clip"] = _w18 + # Chain from previous source + workflow["18"]["inputs"]["model"] = model_source + workflow["18"]["inputs"]["clip"] = clip_source + model_source = ["18", 0] + clip_source = ["18", 1] + logger.debug("Action LoRA: %s @ %s", action_lora_name, _w18) + + # Style/Detailer/Scene LoRA (Node 19) - chains from previous LoRA or checkpoint + # Priority: Style > Detailer > Scene (Scene LoRAs are rare but supported) + target_obj = style or detailer or scene + style_lora_data = target_obj.data.get('lora', {}) if target_obj else {} + style_lora_name = style_lora_data.get('lora_name') + + if style_lora_name and "19" in workflow: + _w19 = _resolve_lora_weight(style_lora_data) + workflow["19"]["inputs"]["lora_name"] = style_lora_name + workflow["19"]["inputs"]["strength_model"] = _w19 + workflow["19"]["inputs"]["strength_clip"] = _w19 + # Chain from previous source + workflow["19"]["inputs"]["model"] = model_source + workflow["19"]["inputs"]["clip"] = clip_source + model_source = ["19", 0] + clip_source = ["19", 1] + logger.debug("Style/Detailer LoRA: %s @ %s", style_lora_name, _w19) + + # Apply connections to all model/clip consumers + workflow["3"]["inputs"]["model"] = model_source + workflow["11"]["inputs"]["model"] = model_source + workflow["13"]["inputs"]["model"] = model_source + + workflow["6"]["inputs"]["clip"] = clip_source + workflow["7"]["inputs"]["clip"] = clip_source + workflow["11"]["inputs"]["clip"] = clip_source + workflow["13"]["inputs"]["clip"] = clip_source + workflow["14"]["inputs"]["clip"] = clip_source + workflow["15"]["inputs"]["clip"] = clip_source + + # 4. Randomize seeds (or use a fixed seed for reproducible batches like Strengths Gallery) + gen_seed = fixed_seed if fixed_seed is not None else random.randint(1, 10**15) + workflow["3"]["inputs"]["seed"] = gen_seed + if "11" in workflow: workflow["11"]["inputs"]["seed"] = gen_seed + if "13" in workflow: workflow["13"]["inputs"]["seed"] = gen_seed + + # 5. Set image dimensions + if "5" in workflow: + if width: + workflow["5"]["inputs"]["width"] = int(width) + if height: + workflow["5"]["inputs"]["height"] = int(height) + + # 6. Apply checkpoint-specific settings (steps, cfg, sampler, base prompts, VAE) + if checkpoint_data: + workflow = _apply_checkpoint_settings(workflow, checkpoint_data) + + # 7. Sync sampler/scheduler from main KSampler to adetailer nodes + sampler_name = workflow["3"]["inputs"].get("sampler_name") + scheduler = workflow["3"]["inputs"].get("scheduler") + for node_id in ["11", "13"]: + if node_id in workflow: + if sampler_name: + workflow[node_id]["inputs"]["sampler_name"] = sampler_name + if scheduler: + workflow[node_id]["inputs"]["scheduler"] = scheduler + + # 8. Cross-deduplicate: remove tags shared between positive and negative + pos_text, neg_text = _cross_dedup_prompts( + workflow["6"]["inputs"]["text"], + workflow["7"]["inputs"]["text"] + ) + workflow["6"]["inputs"]["text"] = pos_text + workflow["7"]["inputs"]["text"] = neg_text + + # 9. Final prompt debug — logged after all transformations are complete + _log_workflow_prompts("_prepare_workflow", workflow) + + return workflow diff --git a/static/flask_session/2029240f6d1128be89ddc32729463129 b/static/flask_session/2029240f6d1128be89ddc32729463129 index 8669b54e5479f0826dc85b2a1cadcb9fac37aed9..1a903dab134530fd94c5ba52e714bafcc618cb91 100644 GIT binary patch literal 9 QcmZQzU|?uq^=8up00X!I1poj5 literal 9 QcmZQzU|?uq^=8!r00XuG1ONa4 diff --git a/static/flask_session/655e209142e59659bd4f552a4a12682c b/static/flask_session/655e209142e59659bd4f552a4a12682c new file mode 100644 index 0000000000000000000000000000000000000000..a0b0f669e73abf7b0b33b51e6a65dd030e2fefc7 GIT binary patch literal 82 zcmX>mdLy%eb*eK11k_IH;fPPmNi5DtEuIoPrH4H?wYWGjJ#|Wtl4o97Voqj?LULkB XYI=TArIkV^L?j+2GNo-wVzC|otH>Uf literal 0 HcmV?d00001 diff --git a/static/js/gallery/gallery-core.js b/static/js/gallery/gallery-core.js new file mode 100644 index 0000000..0f8d33d --- /dev/null +++ b/static/js/gallery/gallery-core.js @@ -0,0 +1,1669 @@ +/** + * GAZE Gallery Enhancement System + * Core Controller Module + * + * Manages gallery views, slideshow modes, and viewer interactions. + * Provides a unified API for all gallery features. + */ + +(function() { + 'use strict'; + + // ============================================================ + // Configuration & Constants + // ============================================================ + + const CONFIG = { + // Layout settings + layouts: { + grid: { minWidth: 160, maxWidth: 210, gap: 8 }, + masonry: { columnWidth: 280, gap: 12, minColumns: 2, maxColumns: 6 }, + justified: { targetHeight: 240, minHeight: 180, maxHeight: 320, gap: 8 }, + mosaic: { columnWidth: 200, minColumns: 3, maxColumns: 8 } + }, + + // Slideshow settings + slideshow: { + intervals: [3000, 5000, 8000, 15000], + defaultInterval: 5000, + transitions: ['fade', 'slide', 'zoom', 'cube'], + defaultTransition: 'fade' + }, + + // Viewer settings + viewer: { + zoomMin: 0.5, + zoomMax: 5, + zoomStep: 0.1, + panSensitivity: 1, + preloadCount: 2 + }, + + // Animation durations (ms) + animations: { + transition: 300, + kenBurns: 8000, + ambient: 500 + }, + + // Storage keys + storage: { + viewMode: 'gaze-gallery-view', + slideshowSettings: 'gaze-gallery-slideshow', + favorites: 'gaze-gallery-favorites' + } + }; + + // ============================================================ + // Gallery State + // ============================================================ + + const state = { + // Current view mode + viewMode: 'grid', // grid, masonry, justified, mosaic + + // Slideshow state + slideshow: { + active: false, + mode: null, // cinema, classic, showcase, ambient + currentIndex: 0, + interval: CONFIG.slideshow.defaultInterval, + transition: CONFIG.slideshow.defaultTransition, + timer: null, + paused: false, + shuffled: false, + shuffleOrder: [] + }, + + // Image data + images: [], + filteredImages: [], + + // Viewer state + viewer: { + open: false, + currentIndex: 0, + zoom: 1, + panX: 0, + panY: 0, + isDragging: false + }, + + // Comparison mode + comparison: { + active: false, + imageA: null, + imageB: null, + mode: 'slider' // slider, split, onion, difference + }, + + // Discovery mode + discovery: { + discovered: new Set(), + favorites: new Set(), + sessionStart: null + }, + + // DOM references + dom: { + container: null, + controls: null + } + }; + + // ============================================================ + // Event Emitter + // ============================================================ + + const events = { + listeners: {}, + + on(event, callback) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(callback); + return () => this.off(event, callback); + }, + + off(event, callback) { + if (!this.listeners[event]) return; + this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); + }, + + emit(event, data) { + if (!this.listeners[event]) return; + this.listeners[event].forEach(cb => cb(data)); + } + }; + + // ============================================================ + // Image Data Manager + // ============================================================ + + const imageManager = { + /** + * Initialize with gallery cards from DOM + */ + initFromDOM(container) { + const cards = container.querySelectorAll('.gallery-card'); + state.images = Array.from(cards).map((card, index) => ({ + index, + src: card.dataset.src, + path: card.dataset.path, + category: card.dataset.category, + slug: card.dataset.slug, + name: card.dataset.name, + element: card, + loaded: false, + aspectRatio: null + })); + state.filteredImages = [...state.images]; + return state.images; + }, + + /** + * Get image by index + */ + get(index) { + return state.filteredImages[index] || null; + }, + + /** + * Get total count + */ + count() { + return state.filteredImages.length; + }, + + /** + * Preload images around current index + */ + async preload(centerIndex, count = CONFIG.viewer.preloadCount) { + const total = state.filteredImages.length; + const toLoad = []; + + for (let i = -count; i <= count; i++) { + let idx = (centerIndex + i + total) % total; + const img = state.filteredImages[idx]; + if (img && !img.loaded) { + toLoad.push(this.loadImage(img)); + } + } + + await Promise.all(toLoad); + }, + + /** + * Load single image and get dimensions + */ + loadImage(imageData) { + return new Promise((resolve) => { + if (imageData.loaded) { + resolve(imageData); + return; + } + + const img = new Image(); + img.onload = () => { + imageData.loaded = true; + imageData.aspectRatio = img.width / img.height; + imageData.width = img.width; + imageData.height = img.height; + resolve(imageData); + }; + img.onerror = () => { + imageData.loaded = true; + imageData.aspectRatio = 1; + resolve(imageData); + }; + img.src = imageData.src; + }); + }, + + /** + * Filter images by criteria + */ + filter(criteria) { + if (!criteria || Object.keys(criteria).length === 0) { + state.filteredImages = [...state.images]; + } else { + state.filteredImages = state.images.filter(img => { + if (criteria.category && img.category !== criteria.category) return false; + if (criteria.slug && img.slug !== criteria.slug) return false; + return true; + }); + } + events.emit('imagesFiltered', state.filteredImages); + return state.filteredImages; + }, + + /** + * Get random unvisited image (for discovery mode) + */ + getRandomUnvisited() { + const unvisited = state.filteredImages.filter( + (_, i) => !state.discovery.discovered.has(i) + ); + if (unvisited.length === 0) { + // Reset discovery + state.discovery.discovered.clear(); + return state.filteredImages[Math.floor(Math.random() * state.filteredImages.length)]; + } + return unvisited[Math.floor(Math.random() * unvisited.length)]; + } + }; + + // ============================================================ + // Layout Engine + // ============================================================ + + const layoutEngine = { + /** + * Switch to a layout mode + */ + setLayout(mode) { + if (!['grid', 'masonry', 'justified', 'mosaic'].includes(mode)) { + console.warn(`Unknown layout mode: ${mode}`); + return; + } + + state.viewMode = mode; + localStorage.setItem(CONFIG.storage.viewMode, mode); + + // Remove all layout classes + const container = state.dom.container; + if (container) { + container.classList.remove('layout-grid', 'layout-masonry', 'layout-justified', 'layout-mosaic'); + container.classList.add(`layout-${mode}`); + } + + // Apply layout + this.apply(mode); + events.emit('layoutChanged', mode); + }, + + /** + * Apply current layout + */ + apply(mode = state.viewMode) { + switch (mode) { + case 'grid': + this.applyGrid(); + break; + case 'masonry': + this.applyMasonry(); + break; + case 'justified': + this.applyJustified(); + break; + case 'mosaic': + this.applyMosaic(); + break; + } + }, + + /** + * Grid layout (enhanced default) + */ + applyGrid() { + const container = state.dom.container; + if (!container) return; + + // Reset any custom positioning + state.images.forEach(img => { + if (img.element) { + img.element.style.cssText = ''; + } + }); + }, + + /** + * Masonry layout + */ + async applyMasonry() { + const container = state.dom.container; + if (!container) return; + + const config = CONFIG.layouts.masonry; + const containerWidth = container.offsetWidth; + const columnCount = Math.min( + config.maxColumns, + Math.max(config.minColumns, Math.floor(containerWidth / config.columnWidth)) + ); + const columnWidth = (containerWidth - (columnCount - 1) * config.gap) / columnCount; + + // Track column heights + const columnHeights = new Array(columnCount).fill(0); + + // Position each image + for (const imgData of state.filteredImages) { + if (!imgData.element) continue; + + // Load image to get aspect ratio + if (!imgData.loaded) { + await imageManager.loadImage(imgData); + } + + // Find shortest column + const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); + const left = shortestColumn * (columnWidth + config.gap); + const top = columnHeights[shortestColumn]; + + // Calculate height based on aspect ratio + const height = columnWidth / (imgData.aspectRatio || 1); + + // Position element + imgData.element.style.cssText = ` + position: absolute; + left: ${left}px; + top: ${top}px; + width: ${columnWidth}px; + height: auto; + `; + + // Update column height + columnHeights[shortestColumn] += height + config.gap; + } + + // Set container height + container.style.height = `${Math.max(...columnHeights)}px`; + container.style.position = 'relative'; + }, + + /** + * Justified layout (row-based) + */ + async applyJustified() { + const container = state.dom.container; + if (!container) return; + + const config = CONFIG.layouts.justified; + const containerWidth = container.offsetWidth; + + // Load all images first to get aspect ratios + await Promise.all(state.filteredImages.map(img => imageManager.loadImage(img))); + + // Build rows using linear partition + const rows = []; + let currentRow = []; + let currentRowWidth = 0; + + for (const imgData of state.filteredImages) { + const scaledWidth = config.targetHeight * (imgData.aspectRatio || 1); + + if (currentRowWidth + scaledWidth > containerWidth && currentRow.length > 0) { + rows.push(this.justifyRow(currentRow, containerWidth, config)); + currentRow = []; + currentRowWidth = 0; + } + + currentRow.push(imgData); + currentRowWidth += scaledWidth + config.gap; + } + + // Handle last row + if (currentRow.length > 0) { + rows.push(this.justifyRow(currentRow, containerWidth, config, true)); + } + + // Position images + let top = 0; + for (const row of rows) { + let left = 0; + for (const item of row.items) { + item.imgData.element.style.cssText = ` + position: absolute; + left: ${left}px; + top: ${top}px; + width: ${item.width}px; + height: ${row.height}px; + `; + item.imgData.element.querySelector('img').style.cssText = ` + width: 100%; + height: 100%; + object-fit: cover; + `; + left += item.width + config.gap; + } + top += row.height + config.gap; + } + + container.style.height = `${top}px`; + container.style.position = 'relative'; + }, + + /** + * Calculate justified row dimensions + */ + justifyRow(images, containerWidth, config, isLast = false) { + const gap = config.gap; + const totalGap = (images.length - 1) * gap; + const availableWidth = containerWidth - totalGap; + + // Calculate row aspect ratio + const rowAspectRatio = images.reduce((sum, img) => sum + (img.aspectRatio || 1), 0); + let rowHeight = availableWidth / rowAspectRatio; + + // Clamp height + rowHeight = Math.min(config.maxHeight, Math.max(config.minHeight, rowHeight)); + + // For last row, don't stretch if too few images + if (isLast && images.length < 3) { + rowHeight = config.targetHeight; + } + + return { + height: rowHeight, + items: images.map(imgData => ({ + imgData, + width: rowHeight * (imgData.aspectRatio || 1) + })) + }; + }, + + /** + * Mosaic layout - seamless grid with no gaps or rounded corners + */ + async applyMosaic() { + const container = state.dom.container; + if (!container) return; + + const config = CONFIG.layouts.mosaic; + const containerWidth = container.offsetWidth; + + // Calculate column count based on container width + const columnCount = Math.min( + config.maxColumns, + Math.max(config.minColumns, Math.floor(containerWidth / config.columnWidth)) + ); + + const itemWidth = containerWidth / columnCount; + const itemHeight = itemWidth; // Square tiles (1:1 aspect ratio) + + // Position each image in a strict grid + state.filteredImages.forEach((imgData, index) => { + if (!imgData.element) return; + + const col = index % columnCount; + const row = Math.floor(index / columnCount); + + imgData.element.style.cssText = ` + position: absolute; + left: ${col * itemWidth}px; + top: ${row * itemHeight}px; + width: ${itemWidth}px; + height: ${itemHeight}px; + margin: 0; + padding: 0; + border-radius: 0; + `; + + // Ensure images fill completely with no gaps + const imgEl = imgData.element.querySelector('img'); + if (imgEl) { + imgEl.style.cssText = ` + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0; + display: block; + `; + } + + // Hide badges and overlays for seamless look + const badge = imgData.element.querySelector('.cat-badge'); + if (badge) badge.style.display = 'none'; + const overlay = imgData.element.querySelector('.overlay'); + if (overlay) overlay.style.opacity = '0'; + }); + + // Calculate total height + const rowCount = Math.ceil(state.filteredImages.length / columnCount); + container.style.height = `${rowCount * itemHeight}px`; + container.style.position = 'relative'; + container.style.gap = '0'; + } + }; + + // ============================================================ + // Enhanced Viewer (Lightbox) + // ============================================================ + + const viewer = { + /** + * Open viewer at specific index + */ + open(index) { + state.viewer.currentIndex = index; + state.viewer.open = true; + state.viewer.zoom = 1; + state.viewer.panX = 0; + state.viewer.panY = 0; + + this.render(); + this.show(); + imageManager.preload(index); + + events.emit('viewerOpened', { index, image: imageManager.get(index) }); + }, + + /** + * Close viewer + */ + close() { + state.viewer.open = false; + this.hide(); + events.emit('viewerClosed'); + }, + + /** + * Navigate to next/previous + */ + navigate(direction) { + const total = imageManager.count(); + if (total === 0) return; + + let newIndex = state.viewer.currentIndex + direction; + if (newIndex < 0) newIndex = total - 1; + if (newIndex >= total) newIndex = 0; + + state.viewer.currentIndex = newIndex; + state.viewer.zoom = 1; + state.viewer.panX = 0; + state.viewer.panY = 0; + + this.render(); + imageManager.preload(newIndex); + + events.emit('viewerNavigated', { index: newIndex, image: imageManager.get(newIndex) }); + }, + + /** + * Zoom to level + */ + setZoom(level, centerX, centerY) { + const oldZoom = state.viewer.zoom; + state.viewer.zoom = Math.min(CONFIG.viewer.zoomMax, Math.max(CONFIG.viewer.zoomMin, level)); + + // Adjust pan to zoom toward center point + if (centerX !== undefined && centerY !== undefined) { + const scale = state.viewer.zoom / oldZoom; + state.viewer.panX = centerX - (centerX - state.viewer.panX) * scale; + state.viewer.panY = centerY - (centerY - state.viewer.panY) * scale; + } + + this.applyTransform(); + events.emit('viewerZoomed', state.viewer.zoom); + }, + + /** + * Handle zoom wheel + */ + handleWheel(e) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -CONFIG.viewer.zoomStep : CONFIG.viewer.zoomStep; + const rect = e.currentTarget.getBoundingClientRect(); + const centerX = e.clientX - rect.left - rect.width / 2; + const centerY = e.clientY - rect.top - rect.height / 2; + this.setZoom(state.viewer.zoom + delta, centerX, centerY); + }, + + /** + * Pan the image + */ + pan(dx, dy) { + if (state.viewer.zoom <= 1) return; + + state.viewer.panX += dx * CONFIG.viewer.panSensitivity; + state.viewer.panY += dy * CONFIG.viewer.panSensitivity; + + this.applyTransform(); + }, + + /** + * Reset zoom and pan + */ + resetTransform() { + state.viewer.zoom = 1; + state.viewer.panX = 0; + state.viewer.panY = 0; + this.applyTransform(); + }, + + /** + * Apply current transform to image + */ + applyTransform() { + const img = document.getElementById('gaze-viewer-image'); + if (!img) return; + + img.style.transform = `translate(${state.viewer.panX}px, ${state.viewer.panY}px) scale(${state.viewer.zoom})`; + }, + + /** + * Create/update viewer DOM + */ + render() { + let modal = document.getElementById('gaze-viewer'); + if (!modal) { + modal = this.createDOM(); + } + + const imgData = imageManager.get(state.viewer.currentIndex); + if (!imgData) return; + + // Update image + const img = document.getElementById('gaze-viewer-image'); + img.src = imgData.src; + img.alt = imgData.name || 'Gallery image'; + + // Update counter + const counter = document.getElementById('gaze-viewer-counter'); + counter.textContent = `${state.viewer.currentIndex + 1} / ${imageManager.count()}`; + + // Update info + const info = document.getElementById('gaze-viewer-info-content'); + info.innerHTML = ` +
${imgData.name || 'Unknown'}
+
+ ${imgData.category || ''} + ${imgData.slug ? `${imgData.slug}` : ''} +
+ `; + + // Update Open Button Link + const openBtn = document.getElementById('gaze-viewer-open-btn'); + if (openBtn) { + if (imgData.category === 'characters') { + openBtn.onclick = () => window.location.href = '/character/' + imgData.slug; + openBtn.textContent = 'Open'; + } else if (imgData.category === 'checkpoints') { + openBtn.onclick = () => window.location.href = '/checkpoint/' + imgData.slug; + openBtn.textContent = 'Open'; + } else { + openBtn.onclick = () => window.location.href = '/generator?' + imgData.category.replace(/s$/, '') + '=' + encodeURIComponent(imgData.slug); + openBtn.textContent = 'Generator'; + } + } + + // Reset transform + this.applyTransform(); + }, + + /** + * Create viewer DOM structure + */ + createDOM() { + const modal = document.createElement('div'); + modal.id = 'gaze-viewer'; + modal.className = 'gaze-viewer'; + modal.innerHTML = ` +
+
+ +
+ +
+ + + + + + +
+
+ 1 / 1 +
+
+ + 100% + + +
+
+ + + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+
+
+ `; + + document.body.appendChild(modal); + this.attachEventListeners(modal); + + return modal; + }, + + /** + * Attach event listeners to viewer + */ + attachEventListeners(modal) { + // Close button + modal.querySelector('.gaze-viewer-close').addEventListener('click', () => this.close()); + + // Backdrop click + modal.querySelector('.gaze-viewer-backdrop').addEventListener('click', () => this.close()); + + // Navigation + modal.querySelector('.gaze-viewer-prev').addEventListener('click', () => this.navigate(-1)); + modal.querySelector('.gaze-viewer-next').addEventListener('click', () => this.navigate(1)); + + // Zoom controls + modal.querySelector('#gaze-viewer-zoom-in').addEventListener('click', () => { + this.setZoom(state.viewer.zoom + 0.25); + }); + modal.querySelector('#gaze-viewer-zoom-out').addEventListener('click', () => { + this.setZoom(state.viewer.zoom - 0.25); + }); + modal.querySelector('#gaze-viewer-reset').addEventListener('click', () => { + this.resetTransform(); + }); + + // Info toggle + modal.querySelector('#gaze-viewer-info-toggle').addEventListener('click', () => { + modal.querySelector('.gaze-viewer-info').classList.toggle('open'); + }); + + // Image wheel zoom + const img = modal.querySelector('.gaze-viewer-img'); + img.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); + + // Image drag to pan + let isDragging = false; + let lastX, lastY; + + img.addEventListener('mousedown', (e) => { + if (state.viewer.zoom > 1) { + isDragging = true; + lastX = e.clientX; + lastY = e.clientY; + img.style.cursor = 'grabbing'; + } + }); + + document.addEventListener('mousemove', (e) => { + if (isDragging) { + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + this.pan(dx, dy); + lastX = e.clientX; + lastY = e.clientY; + } + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + img.style.cursor = state.viewer.zoom > 1 ? 'grab' : 'zoom-in'; + }); + + // Double-click to toggle zoom + img.addEventListener('dblclick', () => { + if (state.viewer.zoom > 1) { + this.resetTransform(); + } else { + this.setZoom(2); + } + }); + + // Slideshow button + modal.querySelector('#gaze-viewer-slideshow').addEventListener('click', () => { + slideshowController.start('classic'); + }); + + // Prompt button + modal.querySelector('#gaze-viewer-prompt-btn').addEventListener('click', () => { + const imgData = imageManager.get(state.viewer.currentIndex); + if (imgData && window.showPrompt) { + window.showPrompt(imgData.path, imgData.name, imgData.category, imgData.slug); + } + }); + + // Delete button + modal.querySelector('#gaze-viewer-delete-btn').addEventListener('click', () => { + const imgData = imageManager.get(state.viewer.currentIndex); + if (imgData && window.openDeleteModal) { + window.openDeleteModal(imgData.path, imgData.name); + viewer.close(); + } + }); + }, + + /** + * Show the viewer + */ + show() { + const modal = document.getElementById('gaze-viewer'); + if (modal) { + modal.classList.add('open'); + document.body.style.overflow = 'hidden'; + } + }, + + /** + * Hide the viewer + */ + hide() { + const modal = document.getElementById('gaze-viewer'); + if (modal) { + modal.classList.remove('open'); + document.body.style.overflow = ''; + } + }, + + /** + * Build thumbnail strip + */ + buildThumbnails() { + const track = document.getElementById('gaze-viewer-thumbs-track'); + if (!track) return; + + track.innerHTML = state.filteredImages.map((img, i) => ` +
+ +
+ `).join(''); + + // Attach click handlers + track.querySelectorAll('.gaze-viewer-thumb').forEach(thumb => { + thumb.addEventListener('click', () => { + const index = parseInt(thumb.dataset.index); + state.viewer.currentIndex = index; + this.render(); + this.updateThumbnailActive(); + }); + }); + }, + + /** + * Update active thumbnail + */ + updateThumbnailActive() { + const track = document.getElementById('gaze-viewer-thumbs-track'); + if (!track) return; + + track.querySelectorAll('.gaze-viewer-thumb').forEach((thumb, i) => { + thumb.classList.toggle('active', i === state.viewer.currentIndex); + }); + + // Scroll active into view + const active = track.querySelector('.gaze-viewer-thumb.active'); + if (active) { + active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + } + } + }; + + // ============================================================ + // Slideshow Controller + // ============================================================ + + const slideshowController = { + /** + * Start slideshow in specified mode + */ + start(mode = 'classic') { + state.slideshow.active = true; + state.slideshow.mode = mode; + state.slideshow.paused = false; + + // If viewer isn't open, open it + if (!state.viewer.open) { + viewer.open(0); + } + + // Setup based on mode + switch (mode) { + case 'cinema': + this.setupCinemaMode(); + break; + case 'classic': + this.setupClassicMode(); + break; + case 'showcase': + this.setupShowcaseMode(); + break; + case 'ambient': + this.setupAmbientMode(); + break; + } + + // Start timer + this.startTimer(); + + events.emit('slideshowStarted', { mode }); + }, + + /** + * Stop slideshow + */ + stop() { + state.slideshow.active = false; + state.slideshow.mode = null; + this.stopTimer(); + this.cleanupModes(); + events.emit('slideshowStopped'); + }, + + /** + * Pause/resume + */ + toggle() { + if (!state.slideshow.active) return; + + state.slideshow.paused = !state.slideshow.paused; + + if (state.slideshow.paused) { + this.stopTimer(); + } else { + this.startTimer(); + } + + events.emit('slideshowToggled', { paused: state.slideshow.paused }); + }, + + /** + * Go to next slide + */ + next() { + if (state.slideshow.shuffled) { + // Use shuffle order + const currentOrderIndex = state.slideshow.shuffleOrder.indexOf(state.viewer.currentIndex); + const nextOrderIndex = (currentOrderIndex + 1) % state.slideshow.shuffleOrder.length; + state.viewer.currentIndex = state.slideshow.shuffleOrder[nextOrderIndex]; + viewer.render(); + } else { + viewer.navigate(1); + } + + // Apply transition effect + this.applyTransition(); + }, + + /** + * Toggle shuffle + */ + toggleShuffle() { + state.slideshow.shuffled = !state.slideshow.shuffled; + + if (state.slideshow.shuffled) { + // Generate shuffle order + state.slideshow.shuffleOrder = [...Array(imageManager.count()).keys()] + .sort(() => Math.random() - 0.5); + } + + events.emit('slideshowShuffleToggled', { shuffled: state.slideshow.shuffled }); + }, + + /** + * Set interval + */ + setInterval(ms) { + state.slideshow.interval = ms; + if (state.slideshow.active && !state.slideshow.paused) { + this.stopTimer(); + this.startTimer(); + } + }, + + /** + * Set transition type + */ + setTransition(type) { + state.slideshow.transition = type; + }, + + /** + * Start auto-advance timer + */ + startTimer() { + this.stopTimer(); + state.slideshow.timer = setInterval(() => this.next(), state.slideshow.interval); + }, + + /** + * Stop timer + */ + stopTimer() { + if (state.slideshow.timer) { + clearInterval(state.slideshow.timer); + state.slideshow.timer = null; + } + }, + + /** + * Apply current transition effect + */ + applyTransition() { + const img = document.getElementById('gaze-viewer-image'); + if (!img) return; + + const transition = state.slideshow.transition; + img.classList.remove('transition-fade', 'transition-slide', 'transition-zoom', 'transition-cube'); + + // Trigger reflow + void img.offsetWidth; + + img.classList.add(`transition-${transition}`); + }, + + /** + * Setup Cinema mode + */ + setupCinemaMode() { + const viewerEl = document.getElementById('gaze-viewer'); + if (viewerEl) { + viewerEl.classList.add('cinema-mode'); + + // Add ambient glow container + if (!document.getElementById('gaze-ambient-glow')) { + const glow = document.createElement('div'); + glow.id = 'gaze-ambient-glow'; + glow.className = 'gaze-ambient-glow'; + viewerEl.querySelector('.gaze-viewer-content').prepend(glow); + } + + // Extract colors for ambient glow + this.updateAmbientGlow(); + } + }, + + /** + * Setup Classic mode + */ + setupClassicMode() { + const viewerEl = document.getElementById('gaze-viewer'); + if (viewerEl) { + viewerEl.classList.add('classic-mode'); + } + }, + + /** + * Setup Showcase mode + */ + setupShowcaseMode() { + const viewerEl = document.getElementById('gaze-viewer'); + if (viewerEl) { + viewerEl.classList.add('showcase-mode'); + + // Add frame element + if (!document.getElementById('gaze-showcase-frame')) { + const frame = document.createElement('div'); + frame.id = 'gaze-showcase-frame'; + frame.className = 'gaze-showcase-frame'; + viewerEl.querySelector('.gaze-viewer-stage').appendChild(frame); + } + } + }, + + /** + * Setup Ambient mode + */ + setupAmbientMode() { + const viewerEl = document.getElementById('gaze-viewer'); + if (viewerEl) { + viewerEl.classList.add('ambient-mode'); + + // Add particle container + if (!document.getElementById('gaze-particles')) { + const particles = document.createElement('div'); + particles.id = 'gaze-particles'; + particles.className = 'gaze-particles'; + viewerEl.querySelector('.gaze-viewer-content').prepend(particles); + + // Initialize particles + this.createParticles(); + } + + // Longer intervals for ambient + state.slideshow.interval = 15000; + this.startTimer(); + } + }, + + /** + * Create floating particles + */ + createParticles() { + const container = document.getElementById('gaze-particles'); + if (!container) return; + + const colors = ['#8b7eff', '#c084fc', '#60a5fa', '#ffffff']; + const count = 40; + + for (let i = 0; i < count; i++) { + const particle = document.createElement('div'); + particle.className = 'gaze-particle'; + particle.style.cssText = ` + left: ${Math.random() * 100}%; + top: ${Math.random() * 100}%; + width: ${2 + Math.random() * 6}px; + height: ${2 + Math.random() * 6}px; + background: ${colors[Math.floor(Math.random() * colors.length)]}; + opacity: ${0.1 + Math.random() * 0.5}; + animation-delay: ${Math.random() * 10}s; + animation-duration: ${15 + Math.random() * 20}s; + `; + container.appendChild(particle); + } + }, + + /** + * Update ambient glow colors from current image + */ + async updateAmbientGlow() { + const imgData = imageManager.get(state.viewer.currentIndex); + if (!imgData) return; + + const glowEl = document.getElementById('gaze-ambient-glow'); + if (!glowEl) return; + + try { + const colors = await this.extractDominantColors(imgData.src); + glowEl.style.background = ` + radial-gradient(ellipse at 20% 20%, ${colors[0]}40 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, ${colors[1]}30 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, ${colors[2]}35 0%, transparent 50%) + `; + } catch (e) { + console.warn('Could not extract colors:', e); + } + }, + + /** + * Extract dominant colors from image + */ + extractDominantColors(src) { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 50; + canvas.height = 50; + ctx.drawImage(img, 0, 0, 50, 50); + + const imageData = ctx.getImageData(0, 0, 50, 50).data; + const colors = []; + + // Sample colors from different regions + const regions = [ + [10, 10], [40, 10], [25, 40] + ]; + + for (const [x, y] of regions) { + const i = (y * 50 + x) * 4; + colors.push(`rgb(${imageData[i]}, ${imageData[i+1]}, ${imageData[i+2]})`); + } + + resolve(colors); + }; + img.onerror = () => resolve(['#8b7eff', '#c084fc', '#60a5fa']); + img.src = src; + }); + }, + + /** + * Cleanup mode-specific elements + */ + cleanupModes() { + const viewerEl = document.getElementById('gaze-viewer'); + if (viewerEl) { + viewerEl.classList.remove('cinema-mode', 'classic-mode', 'showcase-mode', 'ambient-mode'); + } + + // Remove particles + const particles = document.getElementById('gaze-particles'); + if (particles) particles.remove(); + + // Remove ambient glow + const glow = document.getElementById('gaze-ambient-glow'); + if (glow) glow.remove(); + + // Remove frame + const frame = document.getElementById('gaze-showcase-frame'); + if (frame) frame.remove(); + } + }; + + // ============================================================ + // Comparison Mode + // ============================================================ + + const comparisonMode = { + /** + * Enter comparison mode + */ + enter(imageA, imageB) { + state.comparison.active = true; + state.comparison.imageA = imageA; + state.comparison.imageB = imageB; + + this.render(); + events.emit('comparisonStarted', { imageA, imageB }); + }, + + /** + * Exit comparison mode + */ + exit() { + state.comparison.active = false; + this.cleanup(); + events.emit('comparisonEnded'); + }, + + /** + * Set comparison mode type + */ + setMode(mode) { + state.comparison.mode = mode; + this.render(); + }, + + /** + * Render comparison view + */ + render() { + let modal = document.getElementById('gaze-comparison'); + if (!modal) { + modal = this.createDOM(); + } + + const { imageA, imageB, mode } = state.comparison; + + modal.className = `gaze-comparison mode-${mode}`; + modal.classList.add('open'); + + // Update images + document.getElementById('gaze-compare-a').src = imageA.src; + document.getElementById('gaze-compare-b').src = imageB.src; + + document.body.style.overflow = 'hidden'; + }, + + /** + * Create comparison DOM + */ + createDOM() { + const modal = document.createElement('div'); + modal.id = 'gaze-comparison'; + modal.className = 'gaze-comparison'; + modal.innerHTML = ` +
+
+
+
+ Image A +
+
+
+
+
+ Image B +
+
+ +
+
+ + + +
+ + +
+
+ `; + + document.body.appendChild(modal); + this.attachEventListeners(modal); + + return modal; + }, + + /** + * Attach event listeners + */ + attachEventListeners(modal) { + // Close + modal.querySelector('.gaze-comparison-backdrop').addEventListener('click', () => this.exit()); + modal.querySelector('.gaze-compare-close').addEventListener('click', () => this.exit()); + + // Mode buttons + modal.querySelectorAll('.gaze-compare-mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + modal.querySelectorAll('.gaze-compare-mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this.setMode(btn.dataset.mode); + }); + }); + + // Slider drag + const divider = modal.querySelector('#gaze-compare-divider'); + let isDragging = false; + + divider.addEventListener('mousedown', () => isDragging = true); + document.addEventListener('mouseup', () => isDragging = false); + document.addEventListener('mousemove', (e) => { + if (!isDragging || state.comparison.mode !== 'slider') return; + + const rect = modal.querySelector('.gaze-comparison-images').getBoundingClientRect(); + const percent = ((e.clientX - rect.left) / rect.width) * 100; + const clamped = Math.min(95, Math.max(5, percent)); + + divider.style.left = `${clamped}%`; + modal.querySelector('.gaze-compare-pane-a').style.width = `${clamped}%`; + }); + + // Onion opacity + const opacitySlider = modal.querySelector('#gaze-compare-opacity'); + opacitySlider.addEventListener('input', () => { + const opacity = opacitySlider.value / 100; + modal.querySelector('#gaze-compare-b').style.opacity = opacity; + }); + }, + + /** + * Cleanup + */ + cleanup() { + const modal = document.getElementById('gaze-comparison'); + if (modal) { + modal.classList.remove('open'); + document.body.style.overflow = ''; + } + } + }; + + // ============================================================ + // Keyboard Controller + // ============================================================ + + const keyboardController = { + /** + * Initialize keyboard shortcuts + */ + init() { + document.addEventListener('keydown', (e) => this.handleKeydown(e)); + }, + + /** + * Handle keydown event + */ + handleKeydown(e) { + // Skip if typing in input + if (e.target.matches('input, textarea, select')) return; + + const key = e.key.toLowerCase(); + + // Viewer shortcuts (when viewer is open) + if (state.viewer.open) { + switch (key) { + case 'escape': + if (state.slideshow.active) { + slideshowController.stop(); + } else { + viewer.close(); + } + e.preventDefault(); + break; + case 'arrowleft': + viewer.navigate(-1); + e.preventDefault(); + break; + case 'arrowright': + viewer.navigate(1); + e.preventDefault(); + break; + case 'arrowup': + viewer.setZoom(state.viewer.zoom + 0.25); + e.preventDefault(); + break; + case 'arrowdown': + viewer.setZoom(state.viewer.zoom - 0.25); + e.preventDefault(); + break; + case ' ': + if (state.slideshow.active) { + slideshowController.toggle(); + } else { + slideshowController.start('classic'); + } + e.preventDefault(); + break; + case 'f': + this.toggleFullscreen(); + e.preventDefault(); + break; + case 'i': + document.querySelector('.gaze-viewer-info')?.classList.toggle('open'); + e.preventDefault(); + break; + case '0': + viewer.resetTransform(); + e.preventDefault(); + break; + case 'r': + if (state.slideshow.active) { + slideshowController.toggleShuffle(); + } + e.preventDefault(); + break; + } + + // Number keys 1-5 for slideshow speed + if (state.slideshow.active && /[1-5]/.test(key)) { + const speeds = [2000, 3000, 5000, 8000, 15000]; + slideshowController.setInterval(speeds[parseInt(key) - 1]); + e.preventDefault(); + } + } + + // Gallery shortcuts (when viewer is closed) + if (!state.viewer.open) { + switch (key) { + case 'g': + layoutEngine.setLayout('grid'); + e.preventDefault(); + break; + case 'm': + layoutEngine.setLayout('masonry'); + e.preventDefault(); + break; + case 'j': + layoutEngine.setLayout('justified'); + e.preventDefault(); + break; + case 'a': + layoutEngine.setLayout('mosaic'); + e.preventDefault(); + break; + case 's': + viewer.open(0); + slideshowController.start('cinema'); + e.preventDefault(); + break; + } + } + }, + + /** + * Toggle fullscreen + */ + toggleFullscreen() { + if (!document.fullscreenElement) { + const viewer = document.getElementById('gaze-viewer'); + if (viewer) { + viewer.requestFullscreen().catch(console.error); + } + } else { + document.exitFullscreen().catch(console.error); + } + } + }; + + // ============================================================ + // Public API + // ============================================================ + + const GalleryCore = { + // Configuration + CONFIG, + + // State (read-only) + get state() { return state; }, + + // Event handling + on: events.on.bind(events), + off: events.off.bind(events), + + /** + * Initialize the gallery system + */ + init(containerSelector = '.gallery-grid') { + const container = document.querySelector(containerSelector); + if (!container) { + console.warn('Gallery container not found:', containerSelector); + return; + } + + state.dom.container = container; + + // Load images from DOM + imageManager.initFromDOM(container); + + // Restore saved view mode + const savedMode = localStorage.getItem(CONFIG.storage.viewMode); + if (savedMode) { + state.viewMode = savedMode; + } + + // Initialize keyboard shortcuts + keyboardController.init(); + + // Apply current layout + layoutEngine.apply(); + + // Setup click handlers on cards + this.attachCardListeners(); + + console.log(`Gallery initialized with ${imageManager.count()} images`); + events.emit('initialized', { imageCount: imageManager.count() }); + + return this; + }, + + /** + * Attach click listeners to gallery cards + */ + attachCardListeners() { + state.images.forEach((img, index) => { + if (img.element) { + img.element.addEventListener('click', (e) => { + // Don't open viewer if in selection mode + const grid = img.element.closest('.gallery-grid'); + if (grid && grid.classList.contains('selection-mode')) return; + + // Don't open viewer if clicking action buttons + if (e.target.closest('button, a, input')) return; + viewer.open(index); + }); + } + }); + }, + + // Layout methods + setLayout: layoutEngine.setLayout.bind(layoutEngine), + getLayout: () => state.viewMode, + + // Viewer methods + openViewer: viewer.open.bind(viewer), + closeViewer: viewer.close.bind(viewer), + + // Slideshow methods + startSlideshow: slideshowController.start.bind(slideshowController), + stopSlideshow: slideshowController.stop.bind(slideshowController), + toggleSlideshow: slideshowController.toggle.bind(slideshowController), + setSlideshowInterval: slideshowController.setInterval.bind(slideshowController), + setSlideshowTransition: slideshowController.setTransition.bind(slideshowController), + + // Comparison methods + startComparison: comparisonMode.enter.bind(comparisonMode), + stopComparison: comparisonMode.exit.bind(comparisonMode), + + // Image management + getImage: imageManager.get.bind(imageManager), + getImageCount: imageManager.count.bind(imageManager), + filterImages: imageManager.filter.bind(imageManager), + + // Discovery mode + getRandomImage: imageManager.getRandomUnvisited.bind(imageManager), + markDiscovered: (index) => state.discovery.discovered.add(index), + toggleFavorite: (index) => { + if (state.discovery.favorites.has(index)) { + state.discovery.favorites.delete(index); + } else { + state.discovery.favorites.add(index); + } + events.emit('favoriteToggled', { index, isFavorite: state.discovery.favorites.has(index) }); + } + }; + + // Expose to global scope + window.GalleryCore = GalleryCore; + +})(); diff --git a/static/style.css b/static/style.css index 8966358..78806d5 100644 --- a/static/style.css +++ b/static/style.css @@ -175,6 +175,23 @@ h5, h6 { color: var(--text); } background: rgba(255, 255, 255, 0.06); } .queue-btn-active { color: var(--accent-bright) !important; } + +/* Queue button generating animation */ +.queue-btn-generating .queue-icon { + animation: queue-pulse 1.5s ease-in-out infinite; +} + +@keyframes queue-pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.2); + opacity: 0.7; + } +} + .queue-badge { position: absolute; top: -2px; @@ -663,6 +680,7 @@ option { background-color: var(--bg-raised); color: var(--text); } Text / Utility overrides ============================================================ */ .text-muted { color: var(--text-muted) !important; } +.text-accent { color: var(--accent-bright) !important; } .border-bottom { border-color: var(--border) !important; } .border-top { border-color: var(--border) !important; } .border { border-color: var(--border) !important; } @@ -689,7 +707,7 @@ small { color: var(--text-muted); font-size: 0.8em; } .gallery-card { position: relative; overflow: hidden; - border-radius: var(--radius); + border-radius: 0; background: var(--bg-raised); cursor: pointer; border: 1px solid var(--border); @@ -784,6 +802,7 @@ small { color: var(--text-muted); font-size: 0.8em; } transition: background-color 0.4s ease; } .status-dot.status-ok { background-color: #34d399; box-shadow: 0 0 5px rgba(52, 211, 153, 0.55); } +.status-dot.status-warning { background-color: #fbbf24; box-shadow: 0 0 5px rgba(251, 191, 36, 0.55); } .status-dot.status-error { background-color: #f87171; box-shadow: 0 0 5px rgba(248, 113, 113, 0.55); } .status-dot.status-checking { background-color: var(--border-light); animation: status-pulse 1.4s ease-in-out infinite; } @@ -831,3 +850,889 @@ textarea[readonly] { border-color: var(--border) !important; resize: vertical; } + +/* ============================================================ + Gallery Navigation Modal + ============================================================ */ +#galleryModal .modal-content { + background: transparent; + border: none; + box-shadow: none; +} + +#galleryModal .modal-body { + display: flex; + align-items: center; + justify-content: center; + min-height: 90vh; +} + +#galleryImage { + max-height: 90vh; + max-width: 90vw; + object-fit: contain; + border-radius: var(--radius-sm); + box-shadow: 0 0 60px rgba(0, 0, 0, 0.5); +} + +#gallery-prev, #gallery-next { + font-size: 2rem; + line-height: 1; + transition: opacity 0.2s ease, transform 0.2s ease; +} + +#gallery-prev:hover, #gallery-next:hover { + opacity: 1 !important; + transform: translateY(-50%) scale(1.1); +} + +#gallery-counter { + font-family: var(--font-display); + letter-spacing: 0.05em; +} + +.hover-opacity-100:hover { + opacity: 1 !important; +} + +/* ============================================================ + GAZE Gallery Enhancement System + ============================================================ */ + +/* --- View Mode Selector ------------------------------------- */ +.gallery-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.gallery-view-modes { + display: flex; + gap: 0.5rem; +} + +.gallery-view-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.gallery-view-btn:hover { + border-color: var(--accent); + color: var(--text); +} + +.gallery-view-btn.active { + background: var(--accent-glow); + border-color: var(--accent); + color: var(--accent-bright); +} + +.gallery-view-btn svg { + width: 16px; + height: 16px; +} + +.gallery-slideshow-dropdown { + position: relative; +} + +.gallery-slideshow-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: linear-gradient(135deg, var(--accent-dim), var(--accent)); + border: none; + border-radius: var(--radius-sm); + color: white; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.gallery-slideshow-btn:hover { + box-shadow: 0 4px 12px var(--accent-glow-strong); + transform: translateY(-1px); +} + +.gallery-slideshow-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + min-width: 200px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); + z-index: 1000; + display: none; +} + +.gallery-slideshow-menu.open { + display: block; +} + +.gallery-slideshow-menu-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: var(--text); + cursor: pointer; + transition: background 0.2s ease; +} + +.gallery-slideshow-menu-item:hover { + background: var(--accent-glow); +} + +.gallery-slideshow-menu-item:first-child { + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.gallery-slideshow-menu-item:last-child { + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} + +/* --- Layout Variants ---------------------------------------- */ +.layout-masonry .gallery-card { + break-inside: avoid; + margin-bottom: 12px; +} + +.layout-justified .gallery-card img, +.layout-collage .gallery-card img { + aspect-ratio: auto; + height: 100%; +} + +.layout-collage .gallery-card { + overflow: hidden; +} + +.layout-collage .gallery-card:hover { + z-index: 10; + transform: scale(1.02); +} + +/* --- Enhanced Viewer ---------------------------------------- */ +.gaze-viewer { + position: fixed; + inset: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; +} + +.gaze-viewer.open { + display: flex; +} + +.gaze-viewer-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.97); + backdrop-filter: blur(10px); +} + +.gaze-viewer-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.gaze-viewer-stage { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + padding: 80px 120px; +} + +.gaze-viewer-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-sm); + transition: transform 0.3s ease; + cursor: grab; + user-select: none; +} + +.gaze-viewer-img:active { + cursor: grabbing; +} + +.gaze-viewer-img:active { + cursor: grabbing; +} + +/* --- Viewer Navigation -------------------------------------- */ +.gaze-viewer-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + transition: all 0.2s ease; + z-index: 100; +} + +.gaze-viewer-nav:hover { + background: rgba(139, 126, 255, 0.3); + transform: translateY(-50%) scale(1.1); +} + +.gaze-viewer-prev { left: 20px; } +.gaze-viewer-next { right: 20px; } + +/* --- Viewer Toolbar ----------------------------------------- */ +.gaze-viewer-toolbar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent); + z-index: 100; +} + +.gaze-viewer-toolbar-center { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.gaze-viewer-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: var(--radius-sm); + color: white; + cursor: pointer; + transition: all 0.2s ease; +} + +.gaze-viewer-btn:hover { + background: rgba(139, 126, 255, 0.3); +} + +.gaze-viewer-close { + width: 44px; + height: 44px; +} + +.gaze-viewer-close:hover { + background: rgba(248, 113, 113, 0.3); +} + +.gaze-viewer-counter { + color: rgba(255, 255, 255, 0.8); + font-family: var(--font-display); + font-size: 0.875rem; + letter-spacing: 0.05em; +} + +.gaze-viewer-zoom-level { + color: rgba(255, 255, 255, 0.6); + font-size: 0.75rem; + min-width: 3ch; + text-align: center; +} + +/* --- Info Panel --------------------------------------------- */ +.gaze-viewer-info { + position: absolute; + right: 0; + top: 80px; + bottom: 100px; + width: 300px; + background: var(--bg-card); + border-left: 1px solid var(--border); + padding: 1.5rem; + transform: translateX(100%); + transition: transform 0.3s ease; + z-index: 90; + overflow-y: auto; +} + +.gaze-viewer-info.open { + transform: translateX(0); +} + +.viewer-info-title { + font-family: var(--font-display); + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.5rem; +} + +.viewer-info-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.viewer-info-category { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--accent-glow); + border-radius: 4px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent-bright); +} + +.viewer-info-slug { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.75rem; + color: var(--text-muted); +} + +.gaze-viewer-info-actions { + display: flex; + gap: 0.5rem; + margin-top: 1.5rem; +} + +/* --- Thumbnail Strip ---------------------------------------- */ +.gaze-viewer-thumbs { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 80px; + background: rgba(0, 0, 0, 0.8); + padding: 10px 20px; + z-index: 100; +} + +.gaze-viewer-thumbs-track { + display: flex; + gap: 8px; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--border-light) transparent; +} + +.gaze-viewer-thumbs-track::-webkit-scrollbar { + height: 4px; +} + +.gaze-viewer-thumbs-track::-webkit-scrollbar-thumb { + background: var(--border-light); + border-radius: 2px; +} + +.gaze-viewer-thumb { + flex-shrink: 0; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + border: 2px solid transparent; +} + +.gaze-viewer-thumb:hover { + opacity: 0.9; +} + +.gaze-viewer-thumb.active { + opacity: 1; + border-color: var(--accent); + box-shadow: 0 0 10px var(--accent-glow); +} + +.gaze-viewer-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* --- Slideshow Modes ---------------------------------------- */ +.cinema-mode .gaze-viewer-stage { + padding: 0; +} + +.cinema-mode .gaze-viewer-img { + max-width: 90vw; + max-height: 90vh; + box-shadow: 0 0 100px rgba(0, 0, 0, 0.5); +} + +/* Ambient Glow */ +.gaze-ambient-glow { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + transition: background 1s ease; +} + +/* Showcase Frame */ +.showcase-mode .gaze-viewer-stage { + background: var(--bg-raised); +} + +.gaze-showcase-frame { + position: absolute; + inset: 40px; + border: 20px solid var(--bg-card); + box-shadow: + inset 0 0 60px rgba(0,0,0,0.3), + 0 20px 60px rgba(0,0,0,0.4); + pointer-events: none; +} + +/* Ambient Particles */ +.gaze-particles { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; + overflow: hidden; +} + +.gaze-particle { + position: absolute; + border-radius: 50%; + animation: float-particle linear infinite; + filter: blur(1px); +} + +@keyframes float-particle { + 0% { + transform: translateY(100vh) translateX(0); + opacity: 0; + } + 10% { + opacity: var(--particle-opacity, 0.5); + } + 90% { + opacity: var(--particle-opacity, 0.5); + } + 100% { + transform: translateY(-10vh) translateX(20px); + opacity: 0; + } +} + +/* Ken Burns Effect */ +.gaze-viewer-img.ken-burns { + animation: ken-burns 8s ease-in-out forwards; +} + +@keyframes ken-burns { + 0% { + transform: translate(0, 0) scale(1); + } + 100% { + transform: translate(-3%, -2%) scale(1.15); + } +} + +/* --- Transition Effects ------------------------------------- */ +.transition-fade { + animation: transition-fade 0.8s ease; +} + +@keyframes transition-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +.transition-slide { + animation: transition-slide 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes transition-slide { + from { + opacity: 0; + transform: translateX(50px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.transition-zoom { + animation: transition-zoom 0.7s ease; +} + +@keyframes transition-zoom { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.transition-cube { + transform-style: preserve-3d; + perspective: 1000px; + animation: transition-cube 0.8s ease; +} + +@keyframes transition-cube { + from { + transform: rotateY(-90deg); + opacity: 0; + } + to { + transform: rotateY(0); + opacity: 1; + } +} + +/* --- Comparison Mode ---------------------------------------- */ +.gaze-comparison { + position: fixed; + inset: 0; + z-index: 10000; + display: none; +} + +.gaze-comparison.open { + display: flex; +} + +.gaze-comparison-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.98); +} + +.gaze-comparison-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.gaze-comparison-images { + flex: 1; + position: relative; + display: flex; + overflow: hidden; +} + +.gaze-compare-pane { + flex: 1; + position: relative; + overflow: hidden; +} + +.gaze-compare-pane img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.gaze-compare-divider { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 4px; + background: var(--accent); + cursor: ew-resize; + z-index: 10; +} + +.gaze-compare-handle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 32px; + background: var(--accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); +} + +.gaze-compare-handle::before, +.gaze-compare-handle::after { + content: ''; + position: absolute; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; +} + +.gaze-compare-handle::before { + left: 6px; + border-right: 6px solid white; +} + +.gaze-compare-handle::after { + right: 6px; + border-left: 6px solid white; +} + +.mode-split .gaze-compare-pane-a, +.mode-split .gaze-compare-pane-b { + width: 50%; +} + +.mode-onion .gaze-compare-pane-b { + position: absolute; + inset: 0; + opacity: 0.5; +} + +.gaze-comparison-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1rem; + background: var(--bg-card); + border-top: 1px solid var(--border); +} + +.gaze-comparison-modes { + display: flex; + gap: 0.5rem; +} + +.gaze-compare-mode-btn { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.gaze-compare-mode-btn:hover, +.gaze-compare-mode-btn.active { + border-color: var(--accent); + color: var(--accent-bright); + background: var(--accent-glow); +} + +.gaze-compare-close { + padding: 0.5rem 1rem; + background: rgba(248, 113, 113, 0.2); + border: 1px solid var(--danger); + border-radius: var(--radius-sm); + color: var(--danger); + cursor: pointer; + transition: all 0.2s ease; +} + +.gaze-compare-close:hover { + background: rgba(248, 113, 113, 0.3); +} + +/* Info View Metadata */ +.gallery-card .info-meta { + padding: 0.5rem; + background: var(--bg-card); + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 4px; +} + +/* --- Mosaic Layout ------------------------------------------ */ +.layout-mosaic { + display: block; + gap: 0; + padding: 0; +} + +.layout-mosaic .gallery-card { + border-radius: 0; + margin: 0; + padding: 0; + overflow: hidden; +} + +.layout-mosaic .gallery-card img { + border-radius: 0; + display: block; +} + +.layout-mosaic .gallery-card .cat-badge, +.layout-mosaic .gallery-card .overlay, +.layout-mosaic .gallery-card .info-meta { + display: none; +} + +/* --- Responsive --------------------------------------------- */ +@media (max-width: 768px) { + .gaze-viewer-stage { + padding: 60px 20px; + } + + .gaze-viewer-nav { + width: 40px; + height: 40px; + } + + .gaze-viewer-prev { left: 10px; } + .gaze-viewer-next { right: 10px; } + + .gaze-viewer-info { + width: 100%; + top: auto; + bottom: 80px; + height: auto; + max-height: 50vh; + border-left: none; + border-top: 1px solid var(--border); + transform: translateY(100%); + } + + .gaze-viewer-info.open { + transform: translateY(0); + } + + .gallery-controls { + flex-direction: column; + align-items: stretch; + } + + .gallery-view-modes { + justify-content: center; + } + + .gallery-slideshow-dropdown { + align-self: center; + } +} + +/* --- Utility ------------------------------------------------ */ +.gaze-hidden { + display: none !important; +} + +.gaze-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* ============================================================ + Gallery Selection Mode + ============================================================ */ +.gallery-selection-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.gallery-card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 24px; + height: 24px; + cursor: pointer; + z-index: 10; + background: rgba(0, 0, 0, 0.8); + border: 2px solid var(--border-light); + border-radius: 4px; + appearance: none; + transition: all 0.2s ease; +} + +.gallery-card-checkbox:checked { + background: var(--accent); + border-color: var(--accent); +} + +.gallery-card-checkbox:checked::after { + content: '✓'; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 16px; + font-weight: bold; + width: 100%; + height: 100%; +} + +.gallery-card-checkbox:hover { + border-color: var(--accent); +} + +.gallery-card.selected { + border-color: var(--accent) !important; + box-shadow: 0 0 0 3px var(--accent-glow) !important; +} + +.gallery-grid.selection-mode .gallery-card { + cursor: pointer; +} + +.gallery-grid.selection-mode .gallery-card:hover { + transform: translateY(-2px); +} diff --git a/templates/actions/detail.html b/templates/actions/detail.html index 710b585..3a9b410 100644 --- a/templates/actions/detail.html +++ b/templates/actions/detail.html @@ -29,17 +29,6 @@ - - - {% macro selection_checkbox(section, key, label, value) %}
-
+
{% if action.image_path %} {{ action.name }} {% else %} @@ -62,20 +51,12 @@ {% endif %}
-
-
- - -
- -
- {# Character Selector #}
+
+
+ + +
+
- +
+ Seed + + +
+ + + +
@@ -106,7 +105,7 @@
-
+
Preview
@@ -121,10 +120,8 @@ {% if 'special::tags' in preferences %}checked{% endif %} {% elif action.default_fields is not none %} {% if 'special::tags' in action.default_fields %}checked{% endif %} - {% else %} - checked {% endif %}> - +
@@ -148,6 +145,7 @@
@@ -161,6 +159,11 @@ Previews{% if existing_previews %} {{ existing_previews|length }}{% endif %} + {% if action.data.get('lora', {}).get('lora_name', '') != '' %} + + {% endif %}
@@ -256,7 +259,7 @@
{{ existing_previews|length }} preview(s)
- +
@@ -270,10 +273,8 @@ {% for img in existing_previews %}
{% else %} @@ -281,14 +282,18 @@ {% endfor %}
+ + {% set sg_has_lora = action.data.get('lora', {}).get('lora_name', '') != '' %} + {% if sg_has_lora %} +
+ {% set sg_entity = action %} + {% set sg_category = 'actions' %} + {% include 'partials/strengths_gallery.html' %} +
+ {% endif %} - -{% set sg_entity = action %} -{% set sg_category = 'actions' %} -{% set sg_has_lora = action.data.get('lora', {}).get('lora_name', '') != '' %} -{% include 'partials/strengths_gallery.html' %} {% endblock %} {% block scripts %} @@ -361,10 +366,19 @@ selectPreview(jobResult.result.relative_path, jobResult.result.image_url); addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); } + updateSeedFromResult(jobResult.result); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); + // Endless mode callback + window._onEndlessResult = function(jobResult) { + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); + } + }; + const allCharacters = [ {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% endfor %} @@ -382,16 +396,23 @@ if (placeholder) placeholder.remove(); const col = document.createElement('div'); col.className = 'col'; - col.innerHTML = `
- + ${charName ? `
${charName}
` : ''}
`; gallery.insertBefore(col, gallery.firstChild); + + // Add click handler for gallery navigation + const img = col.querySelector('.preview-img'); + img.addEventListener('click', () => { + const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src); + const index = allImages.indexOf(imageUrl); + openGallery(allImages, index); + }); + const badge = document.querySelector('#previews-tab .badge'); if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1; else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' 1'); @@ -449,10 +470,9 @@ }); initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}'); - }); - function showImage(src) { - document.getElementById('modalImage').src = src; - } + // Register preview gallery for navigation + registerGallery('#preview-gallery', '.preview-img'); + }); {% endblock %} diff --git a/templates/actions/index.html b/templates/actions/index.html index 75431a0..3a42a53 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -4,8 +4,8 @@

Action Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Actions...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for action in actions %}
-
+
{% if action.image_path %} {{ action.name }} @@ -90,15 +63,29 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index d763848..d4c7661 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -4,8 +4,8 @@

Checkpoint Library

- - + +
@@ -19,31 +19,6 @@
- -
-
-
-
Batch Generating Checkpoints...
- Starting... -
-
- Overall Batch Progress -
-
-
-
-
-
- - -
-
-
-
-
-
-
-
{% for ckpt in checkpoints %}
@@ -76,11 +51,6 @@ document.addEventListener('DOMContentLoaded', () => { const batchBtn = document.getElementById('batch-generate-btn'); const regenAllBtn = document.getElementById('regenerate-all-btn'); - const progressBar = document.getElementById('batch-progress-bar'); - const taskProgressBar = document.getElementById('task-progress-bar'); - const container = document.getElementById('batch-progress-container'); - const statusText = document.getElementById('batch-status-text'); - const nodeStatus = document.getElementById('batch-node-status'); const ckptNameText = document.getElementById('current-ckpt-name'); const stepProgressText = document.getElementById('current-step-progress'); @@ -113,17 +83,12 @@ batchBtn.disabled = true; regenAllBtn.disabled = true; - container.classList.remove('d-none'); // Phase 1: Queue all jobs upfront - progressBar.style.width = '100%'; - progressBar.textContent = ''; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - nodeStatus.textContent = 'Queuing…'; const jobs = []; for (const ckpt of missing) { - statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; + try { const genResp = await fetch(`/checkpoint/${ckpt.slug}/generate`, { method: 'POST', @@ -138,12 +103,6 @@ } // Phase 2: Poll all concurrently - progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - statusText.textContent = `0 / ${jobs.length} done`; - - let completed = 0; let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { currentItem = item.name; @@ -159,24 +118,11 @@ } catch (err) { console.error(`Failed for ${item.name}:`, err); } - completed++; - const pct = Math.round((completed / jobs.length) * 100); - progressBar.style.width = `${pct}%`; - progressBar.textContent = `${pct}%`; - statusText.textContent = `${completed} / ${jobs.length} done`; })); - progressBar.style.width = '100%'; - progressBar.textContent = '100%'; - statusText.textContent = 'Batch Checkpoint Generation Complete!'; - ckptNameText.textContent = ''; - nodeStatus.textContent = 'Done'; - stepProgressText.textContent = ''; - taskProgressBar.style.width = '0%'; - taskProgressBar.textContent = ''; batchBtn.disabled = false; regenAllBtn.disabled = false; - setTimeout(() => container.classList.add('d-none'), 5000); + alert(`Batch generation complete! ${jobs.length} checkpoint images processed.`); } batchBtn.addEventListener('click', async () => { diff --git a/templates/detail.html b/templates/detail.html index 2e6eb7e..fcc8d64 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -1,21 +1,10 @@ {% extends "layout.html" %} {% block content %} - - -
-
+
{% if character.image_path %} {{ character.name }} {% else %} @@ -23,15 +12,26 @@ {% endif %}
-
-
- - -
- -
+ {# Additional Prompts #} +
+ + +
+
+ + +
+
- +
+ Seed + + +
+ + + +
@@ -53,7 +53,7 @@
-
+
Preview
@@ -63,15 +63,13 @@
Tags
- - +
@@ -86,37 +84,91 @@ - + + +
+
+ + {% set outfits = character.get_available_outfits() %} - {% if outfits|length > 1 %}
- Active Outfit - {{ character.active_outfit or 'default' }} + Wardrobe + {{ outfits|length }} outfit(s)
-
-
- {% for outfit in outfits %} - {% endfor %} -
-
+ + +
+ +
+ + +
+
+ + + {% if character.assigned_outfit_ids %} +
+ +
+ {% for outfit_id in character.assigned_outfit_ids %} + {% set outfit = get_outfit_by_id(outfit_id) %} + {% if outfit %} + + {{ outfit.name }} +
+ +
+
+ {% endif %} + {% endfor %} +
+
+ {% endif %}
- {% endif %}
{% for section, details in character.data.items() %} @@ -138,18 +190,20 @@
{% for key, value in active_wardrobe.items() %} -
- + {{ key.replace('_', ' ') }} + {% if is_default %}DEF{% endif %}
-
{{ value if value else '--' }}
+
{{ value if value else '--' }}
{% endfor %}
@@ -160,33 +214,37 @@
{% if section == 'identity' %} -
- + Character ID + {% if is_name_default %}DEF{% endif %}
-
{{ character.character_id }}
+
{{ character.character_id }}
{% endif %} - + {% for key, value in details.items() %} -
- + {{ key.replace('_', ' ') }} + {% if is_default %}DEF{% endif %}
-
{{ value if value else '--' }}
+
{{ value if value else '--' }}
{% endfor %}
@@ -194,13 +252,20 @@ {% endif %} {% endfor %}
+ +
{# /settings-pane #} + + {% set sg_has_lora = character.data.get('lora', {}).get('lora_name', '') != '' %} + {% if sg_has_lora %} +
+ {% set sg_entity = character %} + {% set sg_category = 'characters' %} + {% include 'partials/strengths_gallery.html' %} +
+ {% endif %} +
{# /tab-content #}
- -{% set sg_entity = character %} -{% set sg_category = 'characters' %} -{% set sg_has_lora = character.data.get('lora', {}).get('lora_name', '') != '' %} -{% include 'partials/strengths_gallery.html' %} {% endblock %} {% block scripts %} @@ -286,6 +351,7 @@ if (jobResult.result?.image_url) { selectPreview(jobResult.result.relative_path, jobResult.result.image_url); } + updateSeedFromResult(jobResult.result); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); @@ -294,10 +360,13 @@ progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); - }); - function showImage(src) { - document.getElementById('modalImage').src = src; - } + // Endless mode callback + window._onEndlessResult = function(jobResult) { + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + } + }; + }); {% endblock %} diff --git a/templates/detailers/detail.html b/templates/detailers/detail.html index d6d2d2f..12c3fd0 100644 --- a/templates/detailers/detail.html +++ b/templates/detailers/detail.html @@ -29,17 +29,6 @@
- - - {% macro selection_checkbox(section, key, label, value) %}
-
+
{% if detailer.image_path %} {{ detailer.name }} {% else %} @@ -64,22 +53,12 @@ {% endif %}
-
-
- - -
- -
- -
- {# Character Selector #}
+ +
+ + + +
@@ -130,7 +117,7 @@
-
+
Preview
@@ -147,6 +134,7 @@
@@ -160,6 +148,11 @@ Previews{% if existing_previews %} {{ existing_previews|length }}{% endif %} + {% if detailer.data.get('lora', {}).get('lora_name', '') != '' %} + + {% endif %}
@@ -225,7 +218,7 @@
{{ existing_previews|length }} preview(s)
- +
@@ -241,8 +234,8 @@
{% else %} @@ -250,14 +243,18 @@ {% endfor %}
+ + {% set sg_has_lora = detailer.data.get('lora', {}).get('lora_name', '') != '' %} + {% if sg_has_lora %} +
+ {% set sg_entity = detailer %} + {% set sg_category = 'detailers' %} + {% include 'partials/strengths_gallery.html' %} +
+ {% endif %}
- -{% set sg_entity = detailer %} -{% set sg_category = 'detailers' %} -{% set sg_has_lora = detailer.data.get('lora', {}).get('lora_name', '') != '' %} -{% include 'partials/strengths_gallery.html' %} {% endblock %} {% block scripts %} @@ -331,10 +328,19 @@ selectPreview(jobResult.result.relative_path, jobResult.result.image_url); addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); } + updateSeedFromResult(jobResult.result); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); + // Endless mode callback + window._onEndlessResult = function(jobResult) { + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); + } + }; + const allCharacters = [ {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% endfor %} @@ -355,8 +361,8 @@ col.innerHTML = `
${charName ? `
${charName}
` : ''} @@ -424,8 +430,6 @@ initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}'); }); - function showImage(src) { - document.getElementById('modalImage').src = src; - } + {% endblock %} diff --git a/templates/detailers/index.html b/templates/detailers/index.html index 78e2430..41f9772 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -4,8 +4,8 @@

Detailer Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Detailers...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for detailer in detailers %}
-
+
{% if detailer.image_path %} {{ detailer.name }} @@ -92,15 +65,29 @@ {% endblock %} {% block scripts %} + + + + {% endblock %} diff --git a/templates/generator.html b/templates/generator.html index 5cee13a..db054ce 100644 --- a/templates/generator.html +++ b/templates/generator.html @@ -18,9 +18,14 @@
- + +
+ Seed + + +
@@ -364,6 +369,7 @@ if (placeholder) placeholder.classList.add('d-none'); resultFooter.classList.remove('d-none'); } + updateSeedFromResult(jobResult.result); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } diff --git a/templates/index.html b/templates/index.html index ebf4c4a..ea1ad4a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,41 +4,14 @@

Character Library

- - + +
- -
-
-
-
Batch Generating...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for char in characters %}
@@ -95,13 +68,6 @@ document.addEventListener('DOMContentLoaded', () => { const batchBtn = document.getElementById('batch-generate-btn'); const regenAllBtn = document.getElementById('regenerate-all-btn'); - const progressBar = document.getElementById('batch-progress-bar'); - const taskProgressBar = document.getElementById('task-progress-bar'); - const container = document.getElementById('batch-progress-container'); - const statusText = document.getElementById('batch-status-text'); - const nodeStatus = document.getElementById('batch-node-status'); - const charNameText = document.getElementById('current-char-name'); - const stepProgressText = document.getElementById('current-step-progress'); async function waitForJob(jobId) { return new Promise((resolve, reject) => { @@ -130,17 +96,10 @@ batchBtn.disabled = true; regenAllBtn.disabled = true; - container.classList.remove('d-none'); // Phase 1: Queue all jobs upfront so the page can be navigated away from - progressBar.style.width = '100%'; - progressBar.textContent = ''; - progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); - nodeStatus.textContent = 'Queuing…'; - const jobs = []; for (const char of missing) { - statusText.textContent = `Queuing ${jobs.length + 1} / ${missing.length}…`; try { const genResp = await fetch(`/character/${char.slug}/generate`, { method: 'POST', @@ -155,16 +114,7 @@ } // Phase 2: Poll all jobs concurrently; update UI as each finishes - progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); - progressBar.style.width = '0%'; - progressBar.textContent = '0%'; - statusText.textContent = `0 / ${jobs.length} done`; - - let completed = 0; - let currentItem = ''; await Promise.all(jobs.map(async ({ item, jobId }) => { - currentItem = item.name; - charNameText.textContent = `Processing: ${currentItem}`; try { const jobResult = await waitForJob(jobId); if (jobResult.result && jobResult.result.image_url) { @@ -176,24 +126,11 @@ } catch (err) { console.error(`Failed for ${item.name}:`, err); } - completed++; - const pct = Math.round((completed / jobs.length) * 100); - progressBar.style.width = `${pct}%`; - progressBar.textContent = `${pct}%`; - statusText.textContent = `${completed} / ${jobs.length} done`; })); - progressBar.style.width = '100%'; - progressBar.textContent = '100%'; - statusText.textContent = 'Batch Complete!'; - charNameText.textContent = ''; - nodeStatus.textContent = 'Done'; - stepProgressText.textContent = ''; - taskProgressBar.style.width = '0%'; - taskProgressBar.textContent = ''; batchBtn.disabled = false; regenAllBtn.disabled = false; - setTimeout(() => container.classList.add('d-none'), 5000); + alert(`Batch generation complete! ${jobs.length} images queued.`); } batchBtn.addEventListener('click', async () => { diff --git a/templates/layout.html b/templates/layout.html index c8e44f4..6976bea 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -41,7 +41,7 @@ ComfyUI - + MCP @@ -111,12 +111,41 @@
+ + + + + + {% block scripts %}{% endblock %} diff --git a/templates/looks/detail.html b/templates/looks/detail.html index 582db3c..e0a3f79 100644 --- a/templates/looks/detail.html +++ b/templates/looks/detail.html @@ -1,6 +1,40 @@ {% extends "layout.html" %} {% block content %} + + + - - -
-
+
{% if look.image_path %} {{ look.name }} @@ -52,20 +75,12 @@ {% endif %}
-
-
- - -
- -
- {# Character Selector #}
-
Defaults to the linked character.
+
Defaults to the first linked character.
+
+ + {# Additional Prompts #} +
+ + +
+
+ +
- +
+ Seed + + +
+ + + +
@@ -99,7 +132,7 @@
-
+
Preview
@@ -114,10 +147,8 @@ {% if 'special::tags' in preferences %}checked{% endif %} {% elif look.default_fields is not none %} {% if 'special::tags' in look.default_fields %}checked{% endif %} - {% else %} - checked {% endif %}> - +
@@ -134,18 +165,41 @@

{{ look.name }}

- {% if look.character_id %} - Linked to: {{ look.character_id.replace('_', ' ').title() }} + {% if linked_character_ids %} + + Linked to: + {% for char_id in linked_character_ids %} + {{ char_id.replace('_', ' ').title() }}{% if not loop.last %} {% endif %} + {% endfor %} + {% endif %}
Edit Profile
+ + Transfer Back to Library
+ + +
+
+
{# Positive prompt #} {% if look.data.positive %} @@ -188,31 +242,40 @@
{% for key, value in lora.items() %} -
- + - {{ key.replace('_', ' ') }} -
-
{{ value if value else '--' }}
+ {{ key.replace('_', ' ') }} + {% if is_default %}DEF{% endif %} + +
{{ value if value else '--' }}
{% endfor %}
{% endif %} + +
{# /settings-pane #} + + {% set sg_has_lora = look.data.get('lora', {}).get('lora_name', '') != '' %} + {% if sg_has_lora %} +
+ {% set sg_entity = look %} + {% set sg_category = 'looks' %} + {% include 'partials/strengths_gallery.html' %} +
+ {% endif %} +
{# /tab-content #}
- -{% set sg_entity = look %} -{% set sg_category = 'looks' %} -{% set sg_has_lora = look.data.get('lora', {}).get('lora_name', '') != '' %} -{% include 'partials/strengths_gallery.html' %} {% endblock %} {% block scripts %} @@ -276,9 +339,14 @@ if (data.error) { alert('Error: ' + data.error); progressContainer.classList.add('d-none'); return; } const jobResult = await waitForJob(data.job_id); if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + updateSeedFromResult(jobResult.result); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); + + window._onEndlessResult = function(jobResult) { + if (jobResult.result?.image_url) selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + }; }); function showImage(src) { diff --git a/templates/looks/edit.html b/templates/looks/edit.html index 22a1ab0..7201b86 100644 --- a/templates/looks/edit.html +++ b/templates/looks/edit.html @@ -17,15 +17,27 @@
+
- - -
Associates this look with a character for generation and LoRA suggestions.
+ +
+
+ Check to link this look to characters +
+
+ {% for char in characters %} +
+ + +
+ {% endfor %} +
+
+
Associates this look with multiple characters for generation and LoRA suggestions.
diff --git a/templates/looks/index.html b/templates/looks/index.html index 17690a7..5c3a5e4 100644 --- a/templates/looks/index.html +++ b/templates/looks/index.html @@ -4,8 +4,8 @@

Looks Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Looks...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for look in looks %}
-
+
{% if look.image_path %} {{ look.name }} @@ -96,15 +69,29 @@ {% endblock %} {% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/templates/outfits/index.html b/templates/outfits/index.html index 862b7ee..be61ed8 100644 --- a/templates/outfits/index.html +++ b/templates/outfits/index.html @@ -4,8 +4,8 @@

Outfit Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Outfits...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for outfit in outfits %}
-
+
{% if outfit.image_path %} {{ outfit.name }} @@ -93,15 +66,29 @@ {% endblock %} {% block scripts %} + diff --git a/templates/scenes/detail.html b/templates/scenes/detail.html index a38e2b5..8fa0c8b 100644 --- a/templates/scenes/detail.html +++ b/templates/scenes/detail.html @@ -29,17 +29,6 @@
- - - {% macro selection_checkbox(section, key, label, value) %}
-
+
{% if scene.image_path %} {{ scene.name }} {% else %} @@ -64,30 +53,38 @@ {% endif %}
-
-
- - -
- -
- -
- {# Character Selector #}
+ {# Additional Prompts #} +
+ + +
+
+ + +
+
- +
+ Seed + + +
+ + + +
@@ -109,7 +106,7 @@
-
+
Preview
@@ -130,6 +127,7 @@
@@ -143,6 +141,11 @@ Previews{% if existing_previews %} {{ existing_previews|length }}{% endif %} + {% if scene.data.get('lora', {}).get('lora_name', '') != '' %} + + {% endif %}
@@ -223,7 +226,7 @@
{{ existing_previews|length }} preview(s)
- +
@@ -237,10 +240,8 @@ {% for img in existing_previews %}
{% else %} @@ -248,14 +249,18 @@ {% endfor %}
+ + {% set sg_has_lora = scene.data.get('lora', {}).get('lora_name', '') != '' %} + {% if sg_has_lora %} +
+ {% set sg_entity = scene %} + {% set sg_category = 'scenes' %} + {% include 'partials/strengths_gallery.html' %} +
+ {% endif %}
- -{% set sg_entity = scene %} -{% set sg_category = 'scenes' %} -{% set sg_has_lora = scene.data.get('lora', {}).get('lora_name', '') != '' %} -{% include 'partials/strengths_gallery.html' %} {% endblock %} {% block scripts %} @@ -328,10 +333,19 @@ selectPreview(jobResult.result.relative_path, jobResult.result.image_url); addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); } + updateSeedFromResult(jobResult.result); } catch (err) { console.error(err); alert('Generation failed: ' + err.message); } finally { progressContainer.classList.add('d-none'); progressBar.classList.remove('progress-bar-striped', 'progress-bar-animated'); } }); + // Endless mode callback + window._onEndlessResult = function(jobResult) { + if (jobResult.result?.image_url) { + selectPreview(jobResult.result.relative_path, jobResult.result.image_url); + addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, ''); + } + }; + const allCharacters = [ {% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} }, {% endfor %} @@ -349,16 +363,23 @@ if (placeholder) placeholder.remove(); const col = document.createElement('div'); col.className = 'col'; - col.innerHTML = `
- + ${charName ? `
${charName}
` : ''}
`; gallery.insertBefore(col, gallery.firstChild); + + // Add click handler for gallery navigation + const img = col.querySelector('.preview-img'); + img.addEventListener('click', () => { + const allImages = Array.from(document.querySelectorAll('#preview-gallery .preview-img')).map(i => i.src); + const index = allImages.indexOf(imageUrl); + openGallery(allImages, index); + }); + const badge = document.querySelector('#previews-tab .badge'); if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1; else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' 1'); @@ -416,10 +437,9 @@ }); initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}'); - }); - function showImage(src) { - document.getElementById('modalImage').src = src; - } + // Register preview gallery for navigation + registerGallery('#preview-gallery', '.preview-img'); + }); {% endblock %} diff --git a/templates/scenes/index.html b/templates/scenes/index.html index a93cf36..0a377e7 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -4,8 +4,8 @@

Scene Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Scenes...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for scene in scenes %}
-
+
{% if scene.image_path %} {{ scene.name }} @@ -90,15 +63,29 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/styles/index.html b/templates/styles/index.html index 8923075..277333b 100644 --- a/templates/styles/index.html +++ b/templates/styles/index.html @@ -4,8 +4,8 @@

Style Library

- - + +
@@ -20,37 +20,10 @@
- -
-
-
-
Batch Generating Styles...
- Starting... -
- -
- Overall Batch Progress -
-
-
-
- -
-
- - -
-
-
-
-
-
-
-
{% for style in styles %}
-
+
{% if style.image_path %} {{ style.name }} @@ -90,15 +63,29 @@ {% endblock %} {% block scripts %} +