From da55b0889be41f63d05787ee2abfad367ab157bb Mon Sep 17 00:00:00 2001 From: Aodhan Collins Date: Thu, 5 Mar 2026 16:46:36 +0000 Subject: [PATCH] Add Docker support and refactor prompt/queue internals - Add Dockerfile, docker-compose.yml, .dockerignore for containerised deployment - Extract _resolve_character(), _ensure_character_fields(), _append_background() helpers to eliminate repeated inline character-field injection and background-tag patterns across all secondary-category generate routes - Add _IDENTITY_KEYS / _WARDROBE_KEYS constants - Fix build_extras_prompt() bug: detailer prompt (a list) was being appended as a single item instead of extended - Replace all per-route _finalize closures with _make_finalize() factory, reducing duplication across 10 generate routes - Add _prune_job_history() called each worker loop iteration to prevent unbounded memory growth - Remove 10 orphaned legacy finalize_generation HTTP routes and check_status route (superseded by job queue API since job-queue branch) - Remove one-time migration scripts (migrate_actions, migrate_detailers, migrate_lora_weight_range, migrate_wardrobe) - Update CLAUDE.md and README.md to document new helpers, queue architecture, and Docker deployment Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 8 + CLAUDE.md | 67 +- Dockerfile | 29 + README.md | 14 + app.py | 1194 ++++++---------------------------- docker-compose.yml | 31 + migrate_actions.py | 63 -- migrate_detailers.py | 61 -- migrate_lora_weight_range.py | 79 --- migrate_wardrobe.py | 153 ----- 10 files changed, 352 insertions(+), 1347 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100644 migrate_actions.py delete mode 100644 migrate_detailers.py delete mode 100644 migrate_lora_weight_range.py delete mode 100644 migrate_wardrobe.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..529424f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +instance/ +flask_session/ +static/uploads/ +tools/ +.git/ diff --git a/CLAUDE.md b/CLAUDE.md index e73e658..f362eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,30 @@ Cross-deduplicates tags between the positive and negative prompt strings. For ea 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. +### `_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. + +### `_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. + +### `_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`. + +### `_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. + +### `_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. + +All `generate` routes pass a `_make_finalize(...)` call as the finalize argument to `_enqueue_job()` instead of defining an inline closure. + +### `_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. + ### `_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`. @@ -225,8 +249,7 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove ### Characters - `GET /` — character gallery (index) - `GET /character/` — character detail with generation UI -- `POST /character//generate` — queue generation (AJAX or form) -- `POST /character//finalize_generation/` — retrieve image from ComfyUI +- `POST /character//generate` — queue generation (AJAX or form); returns `{"job_id": ...}` - `POST /character//replace_cover_from_preview` — promote preview to cover - `GET/POST /character//edit` — edit character data - `POST /character//upload` — upload cover image @@ -239,8 +262,7 @@ Checkpoint JSONs are keyed by `checkpoint_path`. If no JSON exists for a discove Each category follows the same URL pattern: - `GET //` — gallery - `GET //` — detail + generation UI -- `POST ///generate` — queue generation -- `POST ///finalize_generation/` — retrieve image +- `POST ///generate` — queue generation; returns `{"job_id": ...}` - `POST ///replace_cover_from_preview` - `GET/POST ///edit` - `POST ///upload` @@ -254,15 +276,13 @@ Each category follows the same URL pattern: - `GET /looks` — gallery - `GET /look/` — detail - `GET/POST /look//edit` -- `POST /look//generate` -- `POST /look//finalize_generation/` +- `POST /look//generate` — queue generation; returns `{"job_id": ...}` - `POST /look//replace_cover_from_preview` - `GET/POST /look/create` - `POST /looks/rescan` ### Generator (Mix & Match) - `GET/POST /generator` — freeform generator with multi-select accordion UI -- `POST /generator/finalize//` — retrieve image - `POST /generator/preview_prompt` — AJAX: preview composed prompt without generating ### Checkpoints @@ -271,11 +291,16 @@ Each category follows the same URL pattern: - `POST /checkpoint//save_json` - `POST /checkpoints/rescan` +### Job Queue API +All generation routes use the background job queue. Frontend polls: +- `GET /api/queue//status` — returns `{"status": "pending"|"running"|"done"|"failed", "result": {...}}` + +Image retrieval is handled server-side by the `_make_finalize()` callback; there are no separate client-facing finalize routes. + ### Utilities - `POST /set_default_checkpoint` — save default checkpoint to session -- `GET /check_status/` — poll ComfyUI for completion - `GET /get_missing_{characters,outfits,actions,scenes}` — AJAX: list items without cover images -- `POST /generate_missing` — batch generate covers for characters +- `POST /generate_missing` — batch generate covers for all characters missing one (uses job queue) - `POST /clear_all_covers` / `clear_all_{outfit,action,scene}_covers` - `GET /gallery` — global image gallery browsing `static/uploads/` - `GET/POST /settings` — LLM provider configuration @@ -295,7 +320,7 @@ Each category follows the same URL pattern: - `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs) - Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. - **No `{% block head %}` exists** in layout.html — do not try to use it. -- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `prompt_id`, opens a WebSocket to ComfyUI to show progress, then calls the finalize endpoint to save the image. +- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue//status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes. --- @@ -374,6 +399,8 @@ The Flask filesystem session stores: ## Running the App +### Directly (development) + ```bash cd /mnt/alexander/Projects/character-browser source venv/bin/activate @@ -384,6 +411,25 @@ The app runs in debug mode on port 5000 by default. ComfyUI must be running at ` The DB is initialised and all sync functions are called inside `with app.app_context():` at the bottom of `app.py` before `app.run()`. +### Docker + +```bash +docker compose up -d +``` + +The compose file (`docker-compose.yml`) runs two services: +- **`danbooru-mcp`** — built from `https://git.liveaodh.com/aodhan/danbooru-mcp.git`; the MCP tag-search container used by `call_llm()`. +- **`app`** — the Flask app, exposed on host port **5782** → container port 5000. + +Key environment variables set by compose: +- `COMFYUI_URL=http://10.0.0.200:8188` — points at ComfyUI on the Docker host network. +- `SKIP_MCP_AUTOSTART=true` — disables the app's built-in danbooru-mcp launch logic (compose manages it). + +Volumes mounted into the app container: +- `./data`, `./static/uploads`, `./instance`, `./flask_session` — persistent app data. +- `/Volumes/ImageModels:/ImageModels:ro` — model files for checkpoint/LoRA scanning (**requires Docker Desktop file sharing enabled for `/Volumes/ImageModels`**). +- `/var/run/docker.sock` — Docker socket so the app can exec danbooru-mcp tool containers. + --- ## Common Pitfalls @@ -396,3 +442,4 @@ The DB is initialised and all sync functions are called inside `with app.app_con - **AJAX detection**: `request.headers.get('X-Requested-With') == 'XMLHttpRequest'` determines whether to return JSON or redirect. - **Session must be marked modified for JSON responses**: After setting session values in AJAX-responding routes, set `session.modified = True`. - **Detailer `prompt` is a list**: The `prompt` field in detailer JSON is stored as a list of strings (e.g. `["detailed skin", "pores"]`), not a plain string. When merging into `tags` for `build_prompt`, use `extend` for lists and `append` for strings — never append the list object itself or `", ".join()` will fail on the nested list item. +- **`_make_finalize` action semantics**: Pass `action=None` when the route should always update the DB cover (e.g. batch generate, checkpoint generate). Pass `action=request.form.get('action')` for routes that support both "preview" (no DB update) and "replace" (update DB). The factory skips the DB write when `action` is truthy and not `"replace"`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c240cfd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +# Install system deps: git (for danbooru-mcp repo clone) + docker CLI +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + ca-certificates \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list \ + && apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Writable dirs that will typically be bind-mounted at runtime +RUN mkdir -p static/uploads instance flask_session data/characters data/clothing \ + data/actions data/styles data/scenes data/detailers data/checkpoints data/looks + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/README.md b/README.md index 4370686..dd21925 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ A local web-based GUI for managing character profiles (JSON) and generating cons ## Setup & Installation +### Option A — Docker (recommended) + +1. **Clone the repository.** +2. Edit `docker-compose.yml` if needed: + - Set `COMFYUI_URL` to your ComfyUI host/port. + - Adjust the `/Volumes/ImageModels` volume path to your model directory. If you're on Docker Desktop, add the path under **Settings → Resources → File Sharing** first. +3. **Start services:** + ```bash + docker compose up -d + ``` + The app will be available at `http://localhost:5782`. + +### Option B — Local (development) + 1. **Clone the repository** to your local machine. 2. **Configure Paths**: Open `app.py` and update the following variables to match your system: ```python diff --git a/app.py b/app.py index ca2b3d3..30d2553 100644 --- a/app.py +++ b/app.py @@ -29,10 +29,10 @@ app.config['SCENES_DIR'] = 'data/scenes' app.config['DETAILERS_DIR'] = 'data/detailers' app.config['CHECKPOINTS_DIR'] = 'data/checkpoints' app.config['LOOKS_DIR'] = 'data/looks' -app.config['COMFYUI_URL'] = 'http://127.0.0.1:8188' -app.config['ILLUSTRIOUS_MODELS_DIR'] = '/mnt/alexander/AITools/Image Models/Stable-diffusion/Illustrious/' -app.config['NOOB_MODELS_DIR'] = '/mnt/alexander/AITools/Image Models/Stable-diffusion/Noob/' -app.config['LORA_DIR'] = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Looks/' +app.config['COMFYUI_URL'] = os.environ.get('COMFYUI_URL', 'http://127.0.0.1:8188') +app.config['ILLUSTRIOUS_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Illustrious/' +app.config['NOOB_MODELS_DIR'] = '/ImageModels/Stable-diffusion/Noob/' +app.config['LORA_DIR'] = '/ImageModels/lora/Illustrious/Looks/' # Server-side session configuration to avoid cookie size limits app.config['SESSION_TYPE'] = 'filesystem' @@ -149,12 +149,59 @@ def _queue_worker(): 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): + history = get_history(comfy_prompt_id) + outputs = history[comfy_prompt_id]['outputs'] + for node_output in outputs.values(): + if 'images' in node_output: + image_info = node_output['images'][0] + 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" + with open(os.path.join(folder, filename), 'wb') as f: + f.write(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'): + obj = db_model_class.query.filter_by(slug=slug).first() + if obj: + obj.image_path = relative_path + db.session.commit() + return + 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] + + # --------------------------------------------------------------------------- # Queue API routes # --------------------------------------------------------------------------- @@ -284,7 +331,13 @@ def ensure_mcp_server_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( @@ -375,7 +428,7 @@ def get_available_loras(): def get_available_clothing_loras(): """Get LoRAs from the Clothing directory for outfit LoRAs.""" - clothing_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Clothing/' + clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/' loras = [] if os.path.exists(clothing_lora_dir): for f in os.listdir(clothing_lora_dir): @@ -385,7 +438,7 @@ def get_available_clothing_loras(): def get_available_action_loras(): """Get LoRAs from the Poses directory for action LoRAs.""" - poses_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Poses/' + poses_lora_dir = '/ImageModels/lora/Illustrious/Poses/' loras = [] if os.path.exists(poses_lora_dir): for f in os.listdir(poses_lora_dir): @@ -395,7 +448,7 @@ def get_available_action_loras(): def get_available_style_loras(): """Get LoRAs from the Styles directory for style LoRAs.""" - styles_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Styles/' + styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/' loras = [] if os.path.exists(styles_lora_dir): for f in os.listdir(styles_lora_dir): @@ -405,7 +458,7 @@ def get_available_style_loras(): def get_available_detailer_loras(): """Get LoRAs from the Detailers directory for detailer LoRAs.""" - detailers_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Detailers/' + detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/' loras = [] if os.path.exists(detailers_lora_dir): for f in os.listdir(detailers_lora_dir): @@ -415,7 +468,7 @@ def get_available_detailer_loras(): def get_available_scene_loras(): """Get LoRAs from the Backgrounds directory for scene LoRAs.""" - backgrounds_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Backgrounds/' + backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/' loras = [] if os.path.exists(backgrounds_lora_dir): for f in os.listdir(backgrounds_lora_dir): @@ -525,6 +578,51 @@ def _resolve_lora_weight(lora_data, override=None): 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 build_prompt(data, selected_fields=None, default_fields=None, active_outfit='default'): def is_selected(section, key): # Priority: @@ -1455,8 +1553,11 @@ def build_extras_prompt(actions, outfits, scenes, styles, detailers): for detailer in detailers: data = detailer.data - if data.get('prompt'): - parts.append(data['prompt']) + 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']) @@ -1534,25 +1635,7 @@ def generator(): print(f"Queueing generator prompt for {character.character_id}") - _char_slug = character.slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{_char_slug}") - os.makedirs(char_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"characters/{_char_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - return - + _finalize = _make_finalize('characters', character.slug) label = f"Generator: {character.name}" job = _enqueue_job(label, workflow, _finalize) @@ -1570,36 +1653,6 @@ def generator(): actions=actions, outfits=outfits, scenes=scenes, styles=styles, detailers=detailers) -@app.route('/generator/finalize//', methods=['POST']) -def finalize_generator(slug, prompt_id): - character = Character.query.filter_by(slug=slug).first_or_404() - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}") - os.makedirs(char_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f"characters/{slug}/{filename}" - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @app.route('/generator/preview_prompt', methods=['POST']) def generator_preview_prompt(): char_slug = request.form.get('character') @@ -2024,50 +2077,6 @@ def upload_image(slug): return redirect(url_for('detail', slug=slug)) -@app.route('/character//finalize_generation/', methods=['POST']) -def finalize_generation(slug, prompt_id): - character = Character.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - # Create character subfolder - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}") - os.makedirs(char_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - print(f"Image saved to: {os.path.abspath(file_path)}") - - # Handle actions - always save as preview - relative_path = f"characters/{slug}/{filename}" - session[f'preview_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the character's cover image immediately - if action == 'replace': - character.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @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() @@ -2297,81 +2306,31 @@ def clear_all_covers(): @app.route('/generate_missing', methods=['POST']) def generate_missing(): - # Query fresh from database for each check to avoid stale session issues - def get_missing_count(): - return Character.query.filter((Character.image_path == None) | (Character.image_path == '')).count() - - if get_missing_count() == 0: + 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')) - - success_count = 0 - processed = 0 - - # Keep generating until no more missing - while get_missing_count() > 0: - # Get the next character in alphabetical order - character = Character.query.filter( - (Character.image_path == None) | (Character.image_path == '') - ).order_by(Character.name).first() - - if not character: - break - - character_slug = character.slug - character_name = character.name - - processed += 1 - try: - print(f"Batch generating for: {character_name}") - prompt_response = _queue_generation(character, action='replace') - prompt_id = prompt_response['prompt_id'] - - # Simple synchronous wait for each - max_retries = 120 - while max_retries > 0: - history = get_history(prompt_id) - if prompt_id in history: - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{character_slug}") - os.makedirs(char_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - # Re-query the character to ensure it's attached to the session - character_to_update = Character.query.filter_by(slug=character_slug).first() - if character_to_update: - character_to_update.image_path = f"characters/{character_slug}/{filename}" - db.session.commit() - print(f"Saved cover for {character_name}: {character_to_update.image_path}") - success_count += 1 - break - break - time.sleep(2) - max_retries -= 1 - except Exception as e: - print(f"Error generating for {character_name}: {e}") - db.session.rollback() # Rollback on error to ensure clean state - - flash(f"Batch generation complete. Generated {success_count} images.") - return redirect(url_for('index')) -@app.route('/check_status/') -def check_status(prompt_id): - try: - history = get_history(prompt_id) - if prompt_id in history: - return {'status': 'finished'} - return {'status': 'pending'} - except Exception: - return {'status': 'error'}, 500 + 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): @@ -2394,39 +2353,9 @@ def generate_image(slug): ckpt_path, ckpt_data = _get_default_checkpoint() workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data) - # Finalize callback — runs in background thread after ComfyUI finishes - _action = action - _slug = slug - _char_name = character.name - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{_slug}") - os.makedirs(char_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(char_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"characters/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action == 'replace': - char_obj = Character.query.filter_by(slug=_slug).first() - if char_obj: - char_obj.image_path = relative_path - db.session.commit() - return - - label = f"{character.name} – {action}" - job = _enqueue_job(label, workflow, _finalize) - + 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: @@ -2499,7 +2428,7 @@ def rescan_outfits(): @app.route('/outfits/bulk_create', methods=['POST']) def bulk_create_outfits_from_loras(): - clothing_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Clothing/' + clothing_lora_dir = '/ImageModels/lora/Illustrious/Clothing/' if not os.path.exists(clothing_lora_dir): flash('Clothing LoRA directory not found.', 'error') return redirect(url_for('outfits_index')) @@ -2731,15 +2660,10 @@ def generate_outfit_image(slug): character_slug = request.form.get('character_slug', '') character = None - # Handle random character selection - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() - + 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 @@ -2757,24 +2681,10 @@ def generate_outfit_image(slug): 'tags': outfit.data.get('tags', []) } - # When character is selected, merge character identity fields into selected_fields - # so they are included in the prompt + # Merge character identity/defaults into selected_fields so they appear in the prompt if selected_fields: - # Add character identity fields to selection if not already present - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - # Add expression and pose, but NOT scene (outfit previews use simple background) - 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) - # Always include character name - if 'special::name' not in selected_fields: - selected_fields.append('special::name') + _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 @@ -2808,49 +2718,15 @@ def generate_outfit_image(slug): # Build prompts for combined data prompts = build_prompt(combined_data, selected_fields, default_fields) - # Add colored simple background to the main prompt for outfit previews - # Use character's primary_color if available - if character: - primary_color = character.data.get('styles', {}).get('primary_color', '') - if primary_color: - prompts["main"] = f"{prompts['main']}, {primary_color} simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" + _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) - _action = action - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{_slug}") - os.makedirs(outfit_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(outfit_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"outfits/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action == 'replace': - outfit_obj = Outfit.query.filter_by(slug=_slug).first() - if outfit_obj: - outfit_obj.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Outfit: {outfit.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _finalize) + 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']} @@ -2864,50 +2740,6 @@ def generate_outfit_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('outfit_detail', slug=slug)) -@app.route('/outfit//finalize_generation/', methods=['POST']) -def finalize_outfit_generation(slug, prompt_id): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - # Create outfit subfolder - outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") - os.makedirs(outfit_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(outfit_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - print(f"Image saved to: {os.path.abspath(file_path)}") - - # Always save as preview - relative_path = f"outfits/{slug}/{filename}" - session[f'preview_outfit_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the outfit's cover image immediately - if action == 'replace': - outfit.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @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() @@ -3268,15 +3100,10 @@ def generate_action_image(slug): character_slug = request.form.get('character_slug', '') character = None - # Handle random character selection - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() - + 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 @@ -3320,24 +3147,7 @@ def generate_action_image(slug): # Auto-include essential character fields if a character is selected if selected_fields: - # Add character identity fields to selection if not already present - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - - # Always include character name - if 'special::name' not in selected_fields: - selected_fields.append('special::name') - - # Add active wardrobe fields - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if wardrobe.get(key): - field_key = f'wardrobe::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) + _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'] @@ -3428,48 +3238,15 @@ def generate_action_image(slug): prompts["main"] += ", " + ", ".join(extra_parts) print(f"Added extra character: {extra_char.name}") - # Add colored simple background to the main prompt for action previews - if character: - primary_color = character.data.get('styles', {}).get('primary_color', '') - if primary_color: - prompts["main"] = f"{prompts['main']}, {primary_color} simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" + _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) - _action_type = action_type - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{_slug}") - os.makedirs(action_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(action_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"actions/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action_type == 'replace': - action_db = Action.query.filter_by(slug=_slug).first() - if action_db: - action_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Action: {action_obj.name} ({char_label}) – {action_type}" - job = _enqueue_job(label, workflow, _finalize) + job = _enqueue_job(label, workflow, _make_finalize('actions', slug, Action, action_type)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} @@ -3483,48 +3260,6 @@ def generate_action_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('action_detail', slug=slug)) -@app.route('/action//finalize_generation/', methods=['POST']) -def finalize_action_generation(slug, prompt_id): - action_obj = Action.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - # Create action subfolder - action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") - os.makedirs(action_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(action_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - # Always save as preview - relative_path = f"actions/{slug}/{filename}" - session[f'preview_action_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the action's cover image immediately - if action == 'replace': - action_obj.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @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() @@ -3550,7 +3285,7 @@ def save_action_defaults(slug): @app.route('/actions/bulk_create', methods=['POST']) def bulk_create_actions_from_loras(): - actions_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Poses/' + actions_lora_dir = '/ImageModels/lora/Illustrious/Poses/' if not os.path.exists(actions_lora_dir): flash('Actions LoRA directory not found.', 'error') return redirect(url_for('actions_index')) @@ -3923,39 +3658,18 @@ def _build_style_workflow(style_obj, character=None, selected_fields=None): # Merge character identity and wardrobe fields into selected_fields if selected_fields: - # Add character identity fields to selection if not already present - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - - # Always include character name - if 'special::name' not in selected_fields: - selected_fields.append('special::name') - - # Add active wardrobe fields - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if wardrobe.get(key): - field_key = f'wardrobe::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) + _ensure_character_fields(character, selected_fields) else: - # Auto-include essential character fields + # 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') - - # Add active wardrobe wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + for key in _WARDROBE_KEYS: if wardrobe.get(key): selected_fields.append(f'wardrobe::{key}') - - # Add style fields selected_fields.extend(['style::artist_name', 'style::artistic_style', 'lora::lora_triggers']) default_fields = style_obj.default_fields @@ -3977,15 +3691,7 @@ def _build_style_workflow(style_obj, character=None, selected_fields=None): prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - if character: - primary_color = character.data.get('styles', {}).get('primary_color', '') - if primary_color: - prompts["main"] = f"{prompts['main']}, {primary_color} simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" - + _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 @@ -4005,14 +3711,10 @@ def generate_style_image(slug): character_slug = request.form.get('character_slug', '') character = None - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() - + 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 @@ -4020,34 +3722,9 @@ def generate_style_image(slug): # Build workflow using helper (returns workflow dict, not prompt_response) workflow = _build_style_workflow(style_obj, character, selected_fields) - _action = action - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{_slug}") - os.makedirs(style_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(style_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"styles/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action == 'replace': - style_db = Style.query.filter_by(slug=_slug).first() - if style_db: - style_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Style: {style_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _finalize) + 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']} @@ -4061,46 +3738,6 @@ def generate_style_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('style_detail', slug=slug)) -@app.route('/style//finalize_generation/', methods=['POST']) -def finalize_style_generation(slug, prompt_id): - style_obj = Style.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") - os.makedirs(style_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(style_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f"styles/{slug}/{filename}" - session[f'preview_style_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the style's cover image immediately - if action == 'replace': - style_obj.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @app.route('/style//save_defaults', methods=['POST']) def save_style_defaults(slug): style = Style.query.filter_by(slug=slug).first_or_404() @@ -4152,71 +3789,37 @@ def clear_all_style_covers(): @app.route('/styles/generate_missing', methods=['POST']) def generate_missing_styles(): - def get_missing_count(): - return Style.query.filter((Style.image_path == None) | (Style.image_path == '')).count() - - if get_missing_count() == 0: + 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')) - - # Get all characters once to pick from + all_characters = Character.query.all() if not all_characters: flash("No characters available to preview styles with.", "error") return redirect(url_for('styles_index')) - - success_count = 0 - - while get_missing_count() > 0: - style_obj = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).first() - if not style_obj: break - - # Pick a random character for each style for variety + + enqueued = 0 + for style_obj in missing: character = random.choice(all_characters) - - style_slug = style_obj.slug try: - print(f"Batch generating style: {style_obj.name} with character {character.name}") workflow = _build_style_workflow(style_obj, character=character) - prompt_response = queue_prompt(workflow) - prompt_id = prompt_response['prompt_id'] - - max_retries = 120 - while max_retries > 0: - history = get_history(prompt_id) - if prompt_id in history: - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{style_slug}") - os.makedirs(style_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(style_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - style_to_update = Style.query.filter_by(slug=style_slug).first() - if style_to_update: - style_to_update.image_path = f"styles/{style_slug}/{filename}" - db.session.commit() - success_count += 1 - break - break - time.sleep(2) - max_retries -= 1 + + _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 generating for style {style_obj.name}: {e}") - db.session.rollback() - - flash(f"Batch style generation complete. Generated {success_count} images.") + 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(): - styles_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Styles/' + styles_lora_dir = '/ImageModels/lora/Illustrious/Styles/' if not os.path.exists(styles_lora_dir): flash('Styles LoRA directory not found.', 'error') return redirect(url_for('styles_index')) @@ -4581,39 +4184,18 @@ def _queue_scene_generation(scene_obj, character=None, selected_fields=None, cli # Merge character identity and wardrobe fields into selected_fields if selected_fields: - # Add character identity fields to selection if not already present - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - - # Always include character name - if 'special::name' not in selected_fields: - selected_fields.append('special::name') - - # Add active wardrobe fields - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if wardrobe.get(key): - field_key = f'wardrobe::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) + _ensure_character_fields(character, selected_fields) else: - # Auto-include essential character fields + # 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') - - # Add active wardrobe wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + for key in _WARDROBE_KEYS: if wardrobe.get(key): selected_fields.append(f'wardrobe::{key}') - - # Add scene fields selected_fields.extend(['defaults::scene', 'lora::lora_triggers']) default_fields = scene_obj.default_fields @@ -4664,16 +4246,10 @@ def generate_scene_image(slug): # Get selected character (if any) character_slug = request.form.get('character_slug', '') - character = None - - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() - + 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 @@ -4681,34 +4257,9 @@ def generate_scene_image(slug): # Build workflow using helper workflow = _queue_scene_generation(scene_obj, character, selected_fields) - _action = action - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{_slug}") - os.makedirs(scene_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(scene_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"scenes/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action == 'replace': - scene_db = Scene.query.filter_by(slug=_slug).first() - if scene_db: - scene_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Scene: {scene_obj.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _finalize) + 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']} @@ -4722,46 +4273,6 @@ def generate_scene_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('scene_detail', slug=slug)) -@app.route('/scene//finalize_generation/', methods=['POST']) -def finalize_scene_generation(slug, prompt_id): - scene_obj = Scene.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") - os.makedirs(scene_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(scene_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f"scenes/{slug}/{filename}" - session[f'preview_scene_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the scene's cover image immediately - if action == 'replace': - scene_obj.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @app.route('/scene//save_defaults', methods=['POST']) def save_scene_defaults(slug): scene = Scene.query.filter_by(slug=slug).first_or_404() @@ -4787,7 +4298,7 @@ def replace_scene_cover_from_preview(slug): @app.route('/scenes/bulk_create', methods=['POST']) def bulk_create_scenes_from_loras(): - backgrounds_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Backgrounds/' + backgrounds_lora_dir = '/ImageModels/lora/Illustrious/Backgrounds/' if not os.path.exists(backgrounds_lora_dir): flash('Backgrounds LoRA directory not found.', 'error') return redirect(url_for('scenes_index')) @@ -5151,39 +4662,18 @@ def _queue_detailer_generation(detailer_obj, character=None, selected_fields=Non # Merge character identity and wardrobe fields into selected_fields if selected_fields: - # Add character identity fields to selection if not already present - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - - # Always include character name - if 'special::name' not in selected_fields: - selected_fields.append('special::name') - - # Add active wardrobe fields - wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: - if wardrobe.get(key): - field_key = f'wardrobe::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) + _ensure_character_fields(character, selected_fields) else: - # Auto-include essential character fields + # 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') - - # Add active wardrobe wardrobe = character.get_active_wardrobe() - for key in ['full_body', 'headwear', 'top', 'bottom', 'legwear', 'footwear', 'hands', 'gloves', 'accessories']: + for key in _WARDROBE_KEYS: if wardrobe.get(key): selected_fields.append(f'wardrobe::{key}') - - # Add detailer fields selected_fields.extend(['special::tags', 'lora::lora_triggers']) default_fields = detailer_obj.default_fields @@ -5207,15 +4697,7 @@ def _queue_detailer_generation(detailer_obj, character=None, selected_fields=Non prompts = build_prompt(combined_data, selected_fields, default_fields, active_outfit=active_outfit) - # Add colored simple background to the main prompt for detailer previews - if character: - primary_color = character.data.get('styles', {}).get('primary_color', '') - if primary_color: - prompts["main"] = f"{prompts['main']}, {primary_color} simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" - else: - prompts["main"] = f"{prompts['main']}, simple background" + _append_background(prompts, character) if extra_positive: prompts["main"] = f"{prompts['main']}, {extra_positive}" @@ -5237,15 +4719,9 @@ def generate_detailer_image(slug): # Get selected character (if any) character_slug = request.form.get('character_slug', '') - character = None - - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() + 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', '') @@ -5265,34 +4741,9 @@ def generate_detailer_image(slug): # Build workflow using helper workflow = _queue_detailer_generation(detailer_obj, character, selected_fields, action=action_obj, extra_positive=extra_positive, extra_negative=extra_negative) - _action_type = action_type - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{_slug}") - os.makedirs(detailer_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(detailer_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"detailers/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action_type == 'replace': - detailer_db = Detailer.query.filter_by(slug=_slug).first() - if detailer_db: - detailer_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Detailer: {detailer_obj.name} ({char_label}) – {action_type}" - job = _enqueue_job(label, workflow, _finalize) + job = _enqueue_job(label, workflow, _make_finalize('detailers', slug, Detailer, action_type)) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'status': 'queued', 'job_id': job['id']} @@ -5306,46 +4757,6 @@ def generate_detailer_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('detailer_detail', slug=slug)) -@app.route('/detailer//finalize_generation/', methods=['POST']) -def finalize_detailer_generation(slug, prompt_id): - detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") - os.makedirs(detailer_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(detailer_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f"detailers/{slug}/{filename}" - session[f'preview_detailer_{slug}'] = relative_path - session.modified = True # Ensure session is saved for JSON response - - # If action is 'replace', also update the detailer's cover image immediately - if action == 'replace': - detailer_obj.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize error: {e}") - return {'error': str(e)}, 500 - @app.route('/detailer//save_defaults', methods=['POST']) def save_detailer_defaults(slug): detailer = Detailer.query.filter_by(slug=slug).first_or_404() @@ -5387,7 +4798,7 @@ def save_detailer_json(slug): @app.route('/detailers/bulk_create', methods=['POST']) def bulk_create_detailers_from_loras(): - detailers_lora_dir = '/mnt/alexander/AITools/Image Models/lora/Illustrious/Detailers/' + detailers_lora_dir = '/ImageModels/lora/Illustrious/Detailers/' if not os.path.exists(detailers_lora_dir): flash('Detailers LoRA directory not found.', 'error') return redirect(url_for('detailers_index')) @@ -5659,8 +5070,7 @@ def _build_checkpoint_workflow(ckpt_obj, character=None): if wardrobe.get(key): selected_fields.append(f'wardrobe::{key}') prompts = build_prompt(combined_data, selected_fields, None, active_outfit=character.active_outfit) - primary_color = character.data.get('styles', {}).get('primary_color', '') - prompts["main"] = f"{prompts['main']}, {primary_color + ' ' if primary_color else ''}simple background" + _append_background(prompts, character) else: prompts = { "main": "masterpiece, best quality, 1girl, solo, simple background, looking at viewer", @@ -5677,44 +5087,16 @@ def generate_checkpoint_image(slug): ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() try: character_slug = request.form.get('character_slug', '') - character = None - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() + character = _resolve_character(character_slug) + if character_slug == '__random__' and character: + character_slug = character.slug session[f'char_checkpoint_{slug}'] = character_slug workflow = _build_checkpoint_workflow(ckpt, character) - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - ckpt_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{_slug}") - os.makedirs(ckpt_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(ckpt_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"checkpoints/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - ckpt_db = Checkpoint.query.filter_by(slug=_slug).first() - if ckpt_db: - ckpt_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'random' label = f"Checkpoint: {ckpt.name} ({char_label})" - job = _enqueue_job(label, workflow, _finalize) + 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']} @@ -5726,36 +5108,6 @@ def generate_checkpoint_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('checkpoint_detail', slug=slug)) -@app.route('/checkpoint//finalize_generation/', methods=['POST']) -def finalize_checkpoint_generation(slug, prompt_id): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") - os.makedirs(folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - with open(os.path.join(folder, filename), 'wb') as f: - f.write(image_data) - relative_path = f"checkpoints/{slug}/{filename}" - session[f'preview_checkpoint_{slug}'] = relative_path - session.modified = True - if action == 'replace': - ckpt.image_path = relative_path - db.session.commit() - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize checkpoint error: {e}") - return {'error': str(e)}, 500 - @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() @@ -6001,15 +5353,12 @@ def generate_look_image(slug): character = None # Only load a character when the user explicitly selects one - if character_slug == '__random__': - all_characters = Character.query.all() - if all_characters: - character = random.choice(all_characters) - character_slug = character.slug - elif character_slug: - character = Character.query.filter_by(slug=character_slug).first() - if not character: - character = Character.query.filter_by(character_id=character_slug).first() + 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 @@ -6033,18 +5382,8 @@ def generate_look_image(slug): 'lora': look.data.get('lora', {}), 'tags': look.data.get('tags', []) } - for key in ['base_specs', 'hair', 'eyes', 'hands', 'arms', 'torso', 'pelvis', 'legs', 'feet', 'extra']: - if character.data.get('identity', {}).get(key): - field_key = f'identity::{key}' - if field_key not in selected_fields: - selected_fields.append(field_key) - 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') + _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])) @@ -6064,34 +5403,9 @@ def generate_look_image(slug): workflow = _prepare_workflow(workflow, character, prompts, checkpoint=ckpt_path, checkpoint_data=ckpt_data, look=look) - _action = action - _slug = slug - def _finalize(comfy_prompt_id, job): - history = get_history(comfy_prompt_id) - outputs = history[comfy_prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"looks/{_slug}") - os.makedirs(look_folder, exist_ok=True) - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(look_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - relative_path = f"looks/{_slug}/{filename}" - image_url = f'/static/uploads/{relative_path}' - job['result'] = {'image_url': image_url, 'relative_path': relative_path} - if _action == 'replace': - look_db = Look.query.filter_by(slug=_slug).first() - if look_db: - look_db.image_path = relative_path - db.session.commit() - return - char_label = character.name if character else 'no character' label = f"Look: {look.name} ({char_label}) – {action}" - job = _enqueue_job(label, workflow, _finalize) + 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']} @@ -6104,44 +5418,6 @@ def generate_look_image(slug): flash(f"Error during generation: {str(e)}") return redirect(url_for('look_detail', slug=slug)) -@app.route('/look//finalize_generation/', methods=['POST']) -def finalize_look_generation(slug, prompt_id): - look = Look.query.filter_by(slug=slug).first_or_404() - action = request.form.get('action', 'preview') - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'History not found'}, 404 - - outputs = history[prompt_id]['outputs'] - for node_id in outputs: - if 'images' in outputs[node_id]: - image_info = outputs[node_id]['images'][0] - image_data = get_image(image_info['filename'], image_info['subfolder'], image_info['type']) - - look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}') - os.makedirs(look_folder, exist_ok=True) - - filename = f"gen_{int(time.time())}.png" - file_path = os.path.join(look_folder, filename) - with open(file_path, 'wb') as f: - f.write(image_data) - - relative_path = f'looks/{slug}/{filename}' - session[f'preview_look_{slug}'] = relative_path - session.modified = True - - if action == 'replace': - look.image_path = relative_path - db.session.commit() - - return {'success': True, 'image_url': url_for('static', filename=f'uploads/{relative_path}')} - - return {'error': 'No image found in output'}, 404 - except Exception as e: - print(f"Finalize look error: {e}") - return {'error': str(e)}, 500 - @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() @@ -6596,7 +5872,7 @@ def resource_delete(category, slug): 'detailers': app.config['DETAILERS_DIR'], 'checkpoints': app.config['CHECKPOINTS_DIR'], } - _LORA_BASE = '/mnt/alexander/AITools/Image Models/lora/' + _LORA_BASE = '/ImageModels/lora/' if category not in _RESOURCE_MODEL_MAP: return {'error': 'unknown category'}, 400 @@ -6988,50 +6264,6 @@ def strengths_generate(category, slug): return {'error': str(e)}, 500 -@app.route('/strengths///finalize/', methods=['POST']) -def strengths_finalize(category, slug, prompt_id): - if category not in _STRENGTHS_MODEL_MAP: - return {'error': 'unknown category'}, 400 - - strength_value = request.form.get('strength_value', '0.0') - seed = request.form.get('seed', '0') - - try: - history = get_history(prompt_id) - if prompt_id not in history: - return {'error': 'prompt not found in history'}, 404 - - outputs = history[prompt_id].get('outputs', {}) - img_data = None - img_filename = 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')) - img_filename = img['filename'] - break - if img_data: - break - - if not img_data: - return {'error': 'no image in output'}, 500 - - # Save — encode strength value as two-decimal string in filename - strength_str = f"{float(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_{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}" - return {'success': True, 'image_url': f"/static/uploads/{relative}", 'strength_value': strength_value} - - except Exception as e: - print(f"[Strengths] finalize 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') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dbc7364 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + danbooru-mcp: + build: https://git.liveaodh.com/aodhan/danbooru-mcp.git + image: danbooru-mcp:latest + stdin_open: true + restart: unless-stopped + + app: + build: . + ports: + - "5782:5000" + environment: + # ComfyUI runs on the Docker host + COMFYUI_URL: http://10.0.0.200:8188 # Compose manages danbooru-mcp — skip the app's auto-start logic + SKIP_MCP_AUTOSTART: "true" + volumes: + # Persistent data + - ./data:/app/data + - ./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 + # Docker socket so the app can run danbooru-mcp tool containers + - /var/run/docker.sock:/var/run/docker.sock + extra_hosts: + # Resolve host.docker.internal on Linux hosts + - "host.docker.internal:host-gateway" + depends_on: + - danbooru-mcp + restart: unless-stopped diff --git a/migrate_actions.py b/migrate_actions.py deleted file mode 100644 index b43c10b..0000000 --- a/migrate_actions.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import json -from collections import OrderedDict - -ACTIONS_DIR = 'data/actions' - -def migrate_actions(): - if not os.path.exists(ACTIONS_DIR): - print(f"Directory {ACTIONS_DIR} not found.") - return - - count = 0 - for filename in os.listdir(ACTIONS_DIR): - if not filename.endswith('.json'): - continue - - filepath = os.path.join(ACTIONS_DIR, filename) - try: - with open(filepath, 'r') as f: - data = json.load(f, object_pairs_hook=OrderedDict) - - # Check if participants already exists - if 'participants' in data: - print(f"Skipping {filename}: 'participants' already exists.") - continue - - # Create new ordered dict to enforce order - new_data = OrderedDict() - - # Copy keys up to 'action' - found_action = False - for key, value in data.items(): - new_data[key] = value - if key == 'action': - found_action = True - # Insert participants here - new_data['participants'] = { - "solo_focus": "true", - "orientation": "MF" - } - - # If 'action' wasn't found, append at the end - if not found_action: - print(f"Warning: 'action' key not found in {filename}. Appending 'participants' at the end.") - new_data['participants'] = { - "solo_focus": "true", - "orientation": "MF" - } - - # Write back to file - with open(filepath, 'w') as f: - json.dump(new_data, f, indent=2) - - count += 1 - # print(f"Updated {filename}") # Commented out to reduce noise if many files - - except Exception as e: - print(f"Error processing {filename}: {e}") - - print(f"Migration complete. Updated {count} files.") - -if __name__ == "__main__": - migrate_actions() diff --git a/migrate_detailers.py b/migrate_detailers.py deleted file mode 100644 index 604a915..0000000 --- a/migrate_detailers.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import json -import re - -DETAILERS_DIR = 'data/detailers' - -def migrate_detailers(): - if not os.path.exists(DETAILERS_DIR): - print(f"Directory {DETAILERS_DIR} does not exist.") - return - - count = 0 - for filename in os.listdir(DETAILERS_DIR): - if filename.endswith('.json'): - file_path = os.path.join(DETAILERS_DIR, filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - - # Create a new ordered dictionary - new_data = {} - - # Copy existing fields up to 'prompt' - if 'detailer_id' in data: - new_data['detailer_id'] = data['detailer_id'] - if 'detailer_name' in data: - new_data['detailer_name'] = data['detailer_name'] - - # Handle 'prompt' - prompt_val = data.get('prompt', []) - if isinstance(prompt_val, str): - # Split by comma and strip whitespace - new_data['prompt'] = [p.strip() for p in prompt_val.split(',') if p.strip()] - else: - new_data['prompt'] = prompt_val - - # Insert 'focus' - if 'focus' not in data: - new_data['focus'] = "" - else: - new_data['focus'] = data['focus'] - - # Copy remaining fields - for key, value in data.items(): - if key not in ['detailer_id', 'detailer_name', 'prompt', 'focus']: - new_data[key] = value - - # Write back to file - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - - print(f"Migrated {filename}") - count += 1 - - except Exception as e: - print(f"Error processing {filename}: {e}") - - print(f"Migration complete. Processed {count} files.") - -if __name__ == '__main__': - migrate_detailers() diff --git a/migrate_lora_weight_range.py b/migrate_lora_weight_range.py deleted file mode 100644 index 1b85995..0000000 --- a/migrate_lora_weight_range.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration: add lora_weight_min / lora_weight_max to every entity JSON. - -For each JSON file in the target directories we: - - Read the existing lora_weight value (default 1.0 if missing) - - Write lora_weight_min = lora_weight (if not already set) - - Write lora_weight_max = lora_weight (if not already set) - - Leave lora_weight in place (the resolver still uses it as a fallback) - -Directories processed (skip data/checkpoints — no LoRA weight there): - data/characters/ - data/clothing/ - data/actions/ - data/styles/ - data/scenes/ - data/detailers/ - data/looks/ -""" - -import glob -import json -import os -import sys - -DIRS = [ - 'data/characters', - 'data/clothing', - 'data/actions', - 'data/styles', - 'data/scenes', - 'data/detailers', - 'data/looks', -] - -dry_run = '--dry-run' in sys.argv -updated = 0 -skipped = 0 -errors = 0 - -for directory in DIRS: - pattern = os.path.join(directory, '*.json') - for path in sorted(glob.glob(pattern)): - try: - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) - - lora = data.get('lora') - if not isinstance(lora, dict): - skipped += 1 - continue - - weight = float(lora.get('lora_weight', 1.0)) - changed = False - - if 'lora_weight_min' not in lora: - lora['lora_weight_min'] = weight - changed = True - if 'lora_weight_max' not in lora: - lora['lora_weight_max'] = weight - changed = True - - if changed: - if not dry_run: - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write('\n') - print(f" [{'DRY' if dry_run else 'OK'}] {path} weight={weight}") - updated += 1 - else: - skipped += 1 - - except Exception as e: - print(f" [ERR] {path}: {e}", file=sys.stderr) - errors += 1 - -print(f"\nDone. updated={updated} skipped={skipped} errors={errors}") -if dry_run: - print("(dry-run — no files were written)") diff --git a/migrate_wardrobe.py b/migrate_wardrobe.py deleted file mode 100644 index 1baf162..0000000 --- a/migrate_wardrobe.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration script to convert wardrobe structure from flat to nested format. - -Before: - "wardrobe": { - "headwear": "...", - "top": "...", - ... - } - -After: - "wardrobe": { - "default": { - "headwear": "...", - "top": "...", - ... - } - } - -This enables multiple outfits per character. -""" - -import os -import json -from pathlib import Path - - -def migrate_wardrobe(characters_dir: str = "characters", dry_run: bool = False): - """ - Migrate all character JSON files to the new wardrobe structure. - - Args: - characters_dir: Path to the directory containing character JSON files - dry_run: If True, only print what would be changed without modifying files - """ - characters_path = Path(characters_dir) - - if not characters_path.exists(): - print(f"Error: Directory '{characters_dir}' does not exist") - return - - json_files = list(characters_path.glob("*.json")) - - if not json_files: - print(f"No JSON files found in '{characters_dir}'") - return - - migrated_count = 0 - skipped_count = 0 - error_count = 0 - - for json_file in json_files: - try: - with open(json_file, 'r', encoding='utf-8') as f: - data = json.load(f) - - # Check if character has a wardrobe - if 'wardrobe' not in data: - print(f" [SKIP] {json_file.name}: No wardrobe field") - skipped_count += 1 - continue - - wardrobe = data['wardrobe'] - - # Check if already migrated (wardrobe contains 'default' key with nested dict) - if 'default' in wardrobe and isinstance(wardrobe['default'], dict): - # Verify it's actually the new format (has wardrobe keys inside) - expected_keys = {'headwear', 'top', 'legwear', 'footwear', 'hands', 'accessories', - 'inner_layer', 'outer_layer', 'lower_body', 'gloves'} - if any(key in wardrobe['default'] for key in expected_keys): - print(f" [SKIP] {json_file.name}: Already migrated") - skipped_count += 1 - continue - - # Check if wardrobe is a flat structure (not already nested) - # A flat wardrobe has string values, a nested one has dict values - if not isinstance(wardrobe, dict): - print(f" [ERROR] {json_file.name}: Wardrobe is not a dictionary") - error_count += 1 - continue - - # Check if any value is a dict (indicating partial migration or different structure) - has_nested_values = any(isinstance(v, dict) for v in wardrobe.values()) - if has_nested_values: - print(f" [SKIP] {json_file.name}: Wardrobe has nested values, may already be migrated") - skipped_count += 1 - continue - - # Perform migration - new_wardrobe = { - "default": wardrobe - } - data['wardrobe'] = new_wardrobe - - if dry_run: - print(f" [DRY-RUN] {json_file.name}: Would migrate wardrobe") - print(f" Old: {json.dumps(wardrobe, indent=2)[:100]}...") - print(f" New: {json.dumps(new_wardrobe, indent=2)[:100]}...") - else: - with open(json_file, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - print(f" [MIGRATED] {json_file.name}") - - migrated_count += 1 - - except json.JSONDecodeError as e: - print(f" [ERROR] {json_file.name}: Invalid JSON - {e}") - error_count += 1 - except Exception as e: - print(f" [ERROR] {json_file.name}: {e}") - error_count += 1 - - print() - print("=" * 50) - print(f"Migration complete:") - print(f" - Migrated: {migrated_count}") - print(f" - Skipped: {skipped_count}") - print(f" - Errors: {error_count}") - if dry_run: - print() - print("This was a dry run. No files were modified.") - print("Run with --execute to apply changes.") - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser( - description="Migrate character wardrobe structure to support multiple outfits" - ) - parser.add_argument( - "--execute", - action="store_true", - help="Actually modify files (default is dry-run)" - ) - parser.add_argument( - "--dir", - default="characters", - help="Directory containing character JSON files (default: characters)" - ) - - args = parser.parse_args() - - print("=" * 50) - print("Wardrobe Migration Script") - print("=" * 50) - print(f"Directory: {args.dir}") - print(f"Mode: {'EXECUTE' if args.execute else 'DRY-RUN'}") - print("=" * 50) - print() - - migrate_wardrobe(characters_dir=args.dir, dry_run=not args.execute)