Major refactor: deduplicate routes, sync, JS, and fix bugs
- Extract 8 common route patterns into factory functions in routes/shared.py (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) — removes ~1,100 lines across 9 route files - Extract generic _sync_category() in sync.py — 7 sync functions become one-liner wrappers, removing ~350 lines - Extract shared detail page JS into static/js/detail-common.js — all 9 detail templates now call initDetailPage() with minimal config - Extract layout inline JS into static/js/layout-utils.js (~185 lines) - Extract library toolbar JS into static/js/library-toolbar.js - Fix finalize missing-image bug: raise RuntimeError instead of logging warning so job is marked failed - Fix missing scheduler default in _default_checkpoint_data() - Fix N+1 query in Character.get_available_outfits() with batch IN query - Convert all print() to logger across services and routes - Add missing tags display to styles, scenes, detailers, checkpoints detail - Update delete buttons to use trash.png icon with solid red background - Update CLAUDE.md to reflect new architecture Net reduction: ~1,600 lines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
CLAUDE.md
41
CLAUDE.md
@@ -24,12 +24,13 @@ services/
|
|||||||
prompts.py # Prompt building + dedup (build_prompt, build_extras_prompt)
|
prompts.py # Prompt building + dedup (build_prompt, build_extras_prompt)
|
||||||
llm.py # LLM integration + MCP tool calls (call_llm, load_prompt)
|
llm.py # LLM integration + MCP tool calls (call_llm, load_prompt)
|
||||||
mcp.py # MCP/Docker server lifecycle (ensure_mcp_server_running)
|
mcp.py # MCP/Docker server lifecycle (ensure_mcp_server_running)
|
||||||
sync.py # All sync_*() functions + preset resolution helpers
|
sync.py # Generic _sync_category() + sync_characters/sync_checkpoints + preset resolution helpers
|
||||||
job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread)
|
job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread)
|
||||||
file_io.py # LoRA/checkpoint scanning, file helpers
|
file_io.py # LoRA/checkpoint scanning, file helpers
|
||||||
generation.py # Shared generation logic (generate_from_preset)
|
generation.py # Shared generation logic (generate_from_preset)
|
||||||
routes/
|
routes/
|
||||||
__init__.py # register_routes(app) — imports and calls all route modules
|
__init__.py # register_routes(app) — imports and calls all route modules
|
||||||
|
shared.py # Factory functions for common routes (favourite, upload, clone, save_json, etc.)
|
||||||
characters.py # Character CRUD + generation + outfit management
|
characters.py # Character CRUD + generation + outfit management
|
||||||
outfits.py # Outfit routes
|
outfits.py # Outfit routes
|
||||||
actions.py # Action routes
|
actions.py # Action routes
|
||||||
@@ -48,6 +49,10 @@ routes/
|
|||||||
api.py # REST API v1 (preset generation, auth)
|
api.py # REST API v1 (preset generation, auth)
|
||||||
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
|
regenerate.py # Tag regeneration (single + bulk, via LLM queue)
|
||||||
search.py # Global search across resources and gallery images
|
search.py # Global search across resources and gallery images
|
||||||
|
static/js/
|
||||||
|
detail-common.js # Shared detail page JS (initDetailPage: preview, generation, batch, endless, JSON editor)
|
||||||
|
layout-utils.js # Extracted from layout.html (confirmResourceDelete, regenerateTags, initJsonEditor)
|
||||||
|
library-toolbar.js # Library page toolbar (batch generate, clear covers, missing items)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependency Graph
|
### Dependency Graph
|
||||||
@@ -67,16 +72,19 @@ app.py
|
|||||||
│ ├── file_io.py ← models, utils
|
│ ├── file_io.py ← models, utils
|
||||||
│ └── generation.py ← prompts, workflow, job_queue, sync, models
|
│ └── generation.py ← prompts, workflow, job_queue, sync, models
|
||||||
└── routes/
|
└── routes/
|
||||||
├── All route modules ← services/*, utils, models
|
├── shared.py ← models, utils, services (lazy-init to avoid circular imports)
|
||||||
└── (routes never import from other routes)
|
├── All route modules ← services/*, utils, models, shared
|
||||||
|
└── (routes never import from other routes except shared.py)
|
||||||
```
|
```
|
||||||
|
|
||||||
**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services.
|
**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. `routes/shared.py` uses `_init_models()` lazy initialization to avoid circular imports with model classes.
|
||||||
|
|
||||||
### Route Registration Pattern
|
### 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.
|
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.
|
||||||
|
|
||||||
|
Common routes (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) are registered via factory functions in `routes/shared.py`. Each route module calls `register_common_routes(app, 'category_name')` as the first line of its `register_routes()`. The `CATEGORY_CONFIG` dict in `shared.py` maps each category to its model, URL prefix, endpoint names, and directory paths.
|
||||||
|
|
||||||
### Database
|
### 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.
|
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.
|
||||||
|
|
||||||
@@ -186,8 +194,9 @@ Two independent queues with separate worker threads:
|
|||||||
|
|
||||||
### `services/sync.py` — Data Synchronization
|
### `services/sync.py` — Data Synchronization
|
||||||
|
|
||||||
- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category.
|
- **`_sync_category(config_key, model_class, id_field, name_field, extra_fn=None, sync_nsfw=True)`** — Generic sync function handling 80% shared logic. 7 sync functions are one-liner wrappers.
|
||||||
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths.
|
- **`sync_characters()`** / **`sync_checkpoints()`** — Custom sync functions (special ID handling, filesystem scan).
|
||||||
|
- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called by `_sync_category` and custom sync functions.
|
||||||
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
- **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers.
|
||||||
|
|
||||||
### `services/file_io.py` — File & DB Helpers
|
### `services/file_io.py` — File & DB Helpers
|
||||||
@@ -424,7 +433,10 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there
|
|||||||
- Navbar with links to all sections
|
- Navbar with links to all sections
|
||||||
- Global default checkpoint selector (saves to session via AJAX)
|
- Global default checkpoint selector (saves to session via AJAX)
|
||||||
- Resource delete modal (soft/hard) shared across gallery pages
|
- Resource delete modal (soft/hard) shared across gallery pages
|
||||||
- `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs)
|
- **Shared JS files** (extracted from inline scripts):
|
||||||
|
- `static/js/detail-common.js` — `initDetailPage(options)`: favourite toggle, selectPreview, waitForJob, AJAX form submit + polling, addToPreviewGallery, batch generation, endless mode, JSON editor init. All 9 detail templates call this with minimal config.
|
||||||
|
- `static/js/layout-utils.js` — `confirmResourceDelete(mode)`, `regenerateTags(category, slug)`, `initJsonEditor(saveUrl)`
|
||||||
|
- `static/js/library-toolbar.js` — Library page toolbar (batch generate, clear covers, missing items)
|
||||||
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. The `random_gen_image(category, slug)` template global returns a random image path from `static/uploads/<category>/<slug>/` for use as a fallback cover when `image_path` is not set.
|
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. The `random_gen_image(category, slug)` template global returns a random image path from `static/uploads/<category>/<slug>/` for use as a fallback cover when `image_path` is not set.
|
||||||
- **No `{% block head %}` exists** in layout.html — do not try to use it.
|
- **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 `{"job_id": ...}` response, then polls `/api/queue/<job_id>/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.
|
- Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue/<job_id>/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.
|
||||||
@@ -522,14 +534,15 @@ Absolute paths on disk:
|
|||||||
To add a new content category (e.g. "Poses" as a separate concept from Actions), the pattern is:
|
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.
|
1. **Model** (`models.py`): Add a new SQLAlchemy model with the standard fields.
|
||||||
2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` following the pattern of `sync_outfits()`.
|
2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` as a one-liner using `_sync_category()` (or custom if needed).
|
||||||
3. **Data directory** (`app.py`): Add `app.config['NEWCATEGORY_DIR'] = 'data/newcategory'`.
|
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.
|
4. **Shared routes** (`routes/shared.py`): Add entry to `CATEGORY_CONFIG` dict with model, url_prefix, detail_endpoint, config_dir, category_folder, id_field, name_field, endpoint_prefix.
|
||||||
5. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`.
|
5. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Call `register_common_routes(app, 'newcategory')` first, then implement category-specific routes (index, detail, edit, generate, rescan). Follow `routes/outfits.py` or `routes/scenes.py` exactly.
|
||||||
6. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`.
|
6. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`.
|
||||||
7. **Nav**: Add link to navbar in `templates/layout.html`.
|
7. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. Detail template should call `initDetailPage({...})` from `detail-common.js`.
|
||||||
8. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block.
|
8. **Nav**: Add link to navbar in `templates/layout.html`.
|
||||||
9. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion.
|
9. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block.
|
||||||
|
10. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
122
app.py
122
app.py
@@ -66,38 +66,33 @@ if __name__ == '__main__':
|
|||||||
ensure_character_mcp_server_running()
|
ensure_character_mcp_server_running()
|
||||||
init_queue_worker(app)
|
init_queue_worker(app)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Migration: Add active_outfit column if it doesn't exist
|
# --- Helper for safe column additions ---
|
||||||
try:
|
def _add_column(table, column, col_type):
|
||||||
from sqlalchemy import text
|
try:
|
||||||
db.session.execute(text('ALTER TABLE character ADD COLUMN active_outfit VARCHAR(100) DEFAULT \'default\''))
|
db.session.execute(text(f'ALTER TABLE {table} ADD COLUMN {column} {col_type}'))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Added active_outfit column to character table")
|
logger.info("Added %s.%s column", table, column)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
db.session.rollback()
|
||||||
print("active_outfit column already exists")
|
if 'duplicate column name' not in str(e).lower() and 'already exists' not in str(e).lower():
|
||||||
else:
|
logger.debug("Migration note (%s.%s): %s", table, column, e)
|
||||||
print(f"Migration note: {e}")
|
|
||||||
|
|
||||||
# Migration: Add default_fields column to action table if it doesn't exist
|
# --- All migrations (grouped before syncs) ---
|
||||||
try:
|
_add_column('character', 'active_outfit', "VARCHAR(100) DEFAULT 'default'")
|
||||||
from sqlalchemy import text
|
_add_column('action', 'default_fields', 'JSON')
|
||||||
db.session.execute(text('ALTER TABLE action ADD COLUMN default_fields JSON'))
|
_add_column('checkpoint', 'data', 'JSON')
|
||||||
db.session.commit()
|
_add_column('look', 'character_ids', 'JSON')
|
||||||
print("Added default_fields column to action table")
|
|
||||||
except Exception as e:
|
|
||||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
|
||||||
print("default_fields column already exists in action table")
|
|
||||||
else:
|
|
||||||
print(f"Migration action note: {e}")
|
|
||||||
|
|
||||||
# Migration: Add new columns to settings table
|
# Settings columns
|
||||||
columns_to_add = [
|
for col_name, col_type in [
|
||||||
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
|
('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"),
|
||||||
('local_base_url', "VARCHAR(255)"),
|
('local_base_url', 'VARCHAR(255)'),
|
||||||
('local_model', "VARCHAR(100)"),
|
('local_model', 'VARCHAR(100)'),
|
||||||
('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"),
|
('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"),
|
||||||
('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"),
|
('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"),
|
||||||
('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"),
|
('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"),
|
||||||
@@ -105,63 +100,33 @@ if __name__ == '__main__':
|
|||||||
('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"),
|
('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"),
|
||||||
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
|
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
|
||||||
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
|
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
|
||||||
('default_checkpoint', "VARCHAR(500)"),
|
('default_checkpoint', 'VARCHAR(500)'),
|
||||||
('api_key', "VARCHAR(255)"),
|
('api_key', 'VARCHAR(255)'),
|
||||||
]
|
]:
|
||||||
for col_name, col_type in columns_to_add:
|
_add_column('settings', col_name, col_type)
|
||||||
try:
|
|
||||||
db.session.execute(text(f'ALTER TABLE settings ADD COLUMN {col_name} {col_type}'))
|
|
||||||
db.session.commit()
|
|
||||||
print(f"Added {col_name} column to settings table")
|
|
||||||
except Exception as e:
|
|
||||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
print(f"Migration settings note ({col_name}): {e}")
|
|
||||||
|
|
||||||
# Migration: Add is_favourite and is_nsfw columns to all resource tables
|
# is_favourite / is_nsfw on all resource tables
|
||||||
_tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']
|
for tbl in ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']:
|
||||||
for _tbl in _tag_tables:
|
_add_column(tbl, 'is_favourite', 'BOOLEAN DEFAULT 0')
|
||||||
for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]:
|
_add_column(tbl, 'is_nsfw', 'BOOLEAN DEFAULT 0')
|
||||||
try:
|
|
||||||
db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}'))
|
|
||||||
db.session.commit()
|
|
||||||
print(f"Added {_col} column to {_tbl} table")
|
|
||||||
except Exception as e:
|
|
||||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
print(f"Migration note ({_tbl}.{_col}): {e}")
|
|
||||||
|
|
||||||
# Ensure settings exist
|
# Ensure settings exist
|
||||||
if not Settings.query.first():
|
if not Settings.query.first():
|
||||||
db.session.add(Settings())
|
db.session.add(Settings())
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print("Created default settings")
|
logger.info("Created default settings")
|
||||||
|
|
||||||
# Log the default checkpoint on startup
|
# Log default checkpoint
|
||||||
settings = Settings.query.first()
|
settings = Settings.query.first()
|
||||||
if settings and settings.default_checkpoint:
|
if settings and settings.default_checkpoint:
|
||||||
logger.info("=" * 80)
|
logger.info("Default checkpoint: %s", settings.default_checkpoint)
|
||||||
logger.info("DEFAULT CHECKPOINT loaded from database: %s", settings.default_checkpoint)
|
|
||||||
logger.info("=" * 80)
|
|
||||||
else:
|
else:
|
||||||
logger.info("No default checkpoint set in database")
|
logger.info("No default checkpoint set in database")
|
||||||
|
|
||||||
|
# --- Sync all categories ---
|
||||||
sync_characters()
|
sync_characters()
|
||||||
sync_outfits()
|
sync_outfits()
|
||||||
sync_actions()
|
sync_actions()
|
||||||
# Migration: Add data column to checkpoint table
|
|
||||||
try:
|
|
||||||
db.session.execute(text('ALTER TABLE checkpoint ADD COLUMN data JSON'))
|
|
||||||
db.session.commit()
|
|
||||||
print("Added data column to checkpoint table")
|
|
||||||
except Exception as e:
|
|
||||||
if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower():
|
|
||||||
print("data column already exists in checkpoint table")
|
|
||||||
else:
|
|
||||||
print(f"Migration checkpoint note: {e}")
|
|
||||||
|
|
||||||
sync_styles()
|
sync_styles()
|
||||||
sync_detailers()
|
sync_detailers()
|
||||||
sync_scenes()
|
sync_scenes()
|
||||||
@@ -169,20 +134,7 @@ if __name__ == '__main__':
|
|||||||
sync_checkpoints()
|
sync_checkpoints()
|
||||||
sync_presets()
|
sync_presets()
|
||||||
|
|
||||||
# Migration: Convert look.character_id to look.character_ids
|
# --- Post-sync data migration: character_id → 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:
|
try:
|
||||||
looks_with_old_field = Look.query.filter(Look.character_id.isnot(None)).all()
|
looks_with_old_field = Look.query.filter(Look.character_id.isnot(None)).all()
|
||||||
migrated_count = 0
|
migrated_count = 0
|
||||||
@@ -194,8 +146,8 @@ if __name__ == '__main__':
|
|||||||
migrated_count += 1
|
migrated_count += 1
|
||||||
if migrated_count > 0:
|
if migrated_count > 0:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f"Migrated {migrated_count} looks from character_id to character_ids")
|
logger.info("Migrated %d looks from character_id to character_ids", migrated_count)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Migration note (character_ids data): {e}")
|
logger.debug("Migration note (character_ids data): %s", e)
|
||||||
|
|
||||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
danbooru-mcp:
|
danbooru-mcp:
|
||||||
build: https://git.liveaodh.com/aodhan/danbooru-mcp.git
|
build: ./tools/danbooru-mcp
|
||||||
image: danbooru-mcp:latest
|
image: danbooru-mcp:latest
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ class Character(db.Model):
|
|||||||
|
|
||||||
# Add assigned outfits from Outfit table
|
# Add assigned outfits from Outfit table
|
||||||
if self.assigned_outfit_ids:
|
if self.assigned_outfit_ids:
|
||||||
|
assigned = Outfit.query.filter(Outfit.outfit_id.in_(self.assigned_outfit_ids)).all()
|
||||||
|
outfit_by_id = {o.outfit_id: o for o in assigned}
|
||||||
for outfit_id in self.assigned_outfit_ids:
|
for outfit_id in self.assigned_outfit_ids:
|
||||||
outfit = Outfit.query.filter_by(outfit_id=outfit_id).first()
|
outfit = outfit_by_id.get(outfit_id)
|
||||||
if outfit:
|
if outfit:
|
||||||
outfits.append({
|
outfits.append({
|
||||||
'outfit_id': outfit.outfit_id,
|
'outfit_id': outfit.outfit_id,
|
||||||
|
|||||||
@@ -16,24 +16,13 @@ from services.sync import sync_actions
|
|||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file, _LORA_DEFAULTS
|
from utils import allowed_file, _LORA_DEFAULTS
|
||||||
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'actions')
|
||||||
@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')
|
@app.route('/actions')
|
||||||
def actions_index():
|
def actions_index():
|
||||||
@@ -151,40 +140,11 @@ def register_routes(app):
|
|||||||
return redirect(url_for('action_detail', slug=slug))
|
return redirect(url_for('action_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('actions/edit.html', action=action, loras=loras)
|
return render_template('actions/edit.html', action=action, loras=loras)
|
||||||
|
|
||||||
@app.route('/action/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/action/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_action_image(slug):
|
def generate_action_image(slug):
|
||||||
action_obj = Action.query.filter_by(slug=slug).first_or_404()
|
action_obj = Action.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -352,7 +312,7 @@ def register_routes(app):
|
|||||||
# Append to main prompt
|
# Append to main prompt
|
||||||
if extra_parts:
|
if extra_parts:
|
||||||
prompts["main"] += ", " + ", ".join(extra_parts)
|
prompts["main"] += ", " + ", ".join(extra_parts)
|
||||||
print(f"Added extra character: {extra_char.name}")
|
logger.debug("Added extra character: %s", extra_char.name)
|
||||||
|
|
||||||
_append_background(prompts, character)
|
_append_background(prompts, character)
|
||||||
|
|
||||||
@@ -377,33 +337,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('action_detail', slug=slug))
|
return redirect(url_for('action_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('action_detail', slug=slug))
|
return redirect(url_for('action_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/action/<path:slug>/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/<path:slug>/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'])
|
@app.route('/actions/bulk_create', methods=['POST'])
|
||||||
def bulk_create_actions_from_loras():
|
def bulk_create_actions_from_loras():
|
||||||
_s = Settings.query.first()
|
_s = Settings.query.first()
|
||||||
@@ -556,7 +495,7 @@ def register_routes(app):
|
|||||||
action_data['action_id'] = safe_slug
|
action_data['action_id'] = safe_slug
|
||||||
action_data['action_name'] = name
|
action_data['action_name'] = name
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error: {e}")
|
logger.exception("LLM error: %s", e)
|
||||||
flash(f"Failed to generate action profile: {e}")
|
flash(f"Failed to generate action profile: {e}")
|
||||||
return render_template('actions/create.html', form_data=form_data)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
else:
|
else:
|
||||||
@@ -587,74 +526,9 @@ def register_routes(app):
|
|||||||
flash('Action created successfully!')
|
flash('Action created successfully!')
|
||||||
return redirect(url_for('action_detail', slug=safe_slug))
|
return redirect(url_for('action_detail', slug=safe_slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create action: {e}")
|
flash(f"Failed to create action: {e}")
|
||||||
return render_template('actions/create.html', form_data=form_data)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('actions/create.html', form_data=form_data)
|
return render_template('actions/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/action/<path:slug>/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/<path:slug>/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}
|
|
||||||
|
|
||||||
@app.route('/action/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_action_favourite(slug):
|
|
||||||
action = Action.query.filter_by(slug=slug).first_or_404()
|
|
||||||
action.is_favourite = not action.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': action.is_favourite}
|
|
||||||
return redirect(url_for('action_detail', slug=slug))
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import re
|
|||||||
|
|
||||||
from flask import flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
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 models import Action, Character, Detailer, Look, Outfit, Scene, Style, db
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.job_queue import _enqueue_job, _make_finalize
|
from services.job_queue import _enqueue_job, _make_finalize
|
||||||
@@ -14,12 +12,13 @@ from services.llm import call_character_mcp_tool, call_llm, load_prompt
|
|||||||
from services.prompts import build_prompt
|
from services.prompts import build_prompt
|
||||||
from services.sync import sync_characters
|
from services.sync import sync_characters
|
||||||
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
from services.workflow import _get_default_checkpoint, _prepare_workflow
|
||||||
from utils import allowed_file
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'characters')
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -165,7 +164,7 @@ def register_routes(app):
|
|||||||
new_data[f'{target_type}_name'] = new_name
|
new_data[f'{target_type}_name'] = new_name
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM transfer error: {e}")
|
logger.exception("LLM transfer error: %s", e)
|
||||||
flash(f'Failed to generate {target_type} with AI: {e}')
|
flash(f'Failed to generate {target_type} with AI: {e}')
|
||||||
return redirect(url_for('transfer_character', slug=slug))
|
return redirect(url_for('transfer_character', slug=slug))
|
||||||
else:
|
else:
|
||||||
@@ -212,7 +211,7 @@ def register_routes(app):
|
|||||||
return redirect(url_for('detailer_detail', slug=safe_slug))
|
return redirect(url_for('detailer_detail', slug=safe_slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Transfer save error: {e}")
|
logger.exception("Transfer save error: %s", e)
|
||||||
flash(f'Failed to save transferred {target_type}: {e}')
|
flash(f'Failed to save transferred {target_type}: {e}')
|
||||||
return redirect(url_for('transfer_character', slug=slug))
|
return redirect(url_for('transfer_character', slug=slug))
|
||||||
|
|
||||||
@@ -356,7 +355,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character."""
|
|||||||
logger.info(f"Generated outfit: {outfit_name} for character {name}")
|
logger.info(f"Generated outfit: {outfit_name} for character {name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Outfit generation error: {e}")
|
logger.exception("Outfit generation error: %s", e)
|
||||||
# Fall back to default
|
# Fall back to default
|
||||||
default_outfit_id = 'default'
|
default_outfit_id = 'default'
|
||||||
|
|
||||||
@@ -404,7 +403,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
char_data.pop('wardrobe', None)
|
char_data.pop('wardrobe', None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error: {e}")
|
logger.exception("LLM error: %s", e)
|
||||||
error_msg = f"Failed to generate character profile: {e}"
|
error_msg = f"Failed to generate character profile: {e}"
|
||||||
if is_ajax:
|
if is_ajax:
|
||||||
return jsonify({'error': error_msg}), 500
|
return jsonify({'error': error_msg}), 500
|
||||||
@@ -473,7 +472,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
return redirect(url_for('detail', slug=safe_slug))
|
return redirect(url_for('detail', slug=safe_slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
error_msg = f"Failed to create character: {e}"
|
error_msg = f"Failed to create character: {e}"
|
||||||
if is_ajax:
|
if is_ajax:
|
||||||
return jsonify({'error': error_msg}), 500
|
return jsonify({'error': error_msg}), 500
|
||||||
@@ -565,7 +564,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
return redirect(url_for('detail', slug=slug))
|
return redirect(url_for('detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('edit.html', character=character, loras=loras, char_looks=char_looks)
|
return render_template('edit.html', character=character, loras=loras, char_looks=char_looks)
|
||||||
@@ -757,60 +756,6 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
|
|
||||||
return redirect(url_for('edit_character', slug=slug))
|
return redirect(url_for('edit_character', slug=slug))
|
||||||
|
|
||||||
@app.route('/character/<path:slug>/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/<path:slug>/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'])
|
@app.route('/generate_missing', methods=['POST'])
|
||||||
def generate_missing():
|
def generate_missing():
|
||||||
missing = Character.query.filter(
|
missing = Character.query.filter(
|
||||||
@@ -834,7 +779,7 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
_enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character))
|
_enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character))
|
||||||
enqueued += 1
|
enqueued += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error queuing cover generation for {character.name}: {e}")
|
logger.exception("Error queuing cover generation for %s: %s", character.name, e)
|
||||||
|
|
||||||
flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.")
|
flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.")
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
@@ -882,26 +827,9 @@ Do NOT include a wardrobe section - the outfit is handled separately."""
|
|||||||
return redirect(url_for('detail', slug=slug))
|
return redirect(url_for('detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('detail', slug=slug))
|
return redirect(url_for('detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/character/<path:slug>/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('/character/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_character_favourite(slug):
|
|
||||||
character = Character.query.filter_by(slug=slug).first_or_404()
|
|
||||||
character.is_favourite = not character.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': character.is_favourite}
|
|
||||||
return redirect(url_for('detail', slug=slug))
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ from services.sync import sync_checkpoints, _default_checkpoint_data
|
|||||||
from services.file_io import get_available_checkpoints
|
from services.file_io import get_available_checkpoints
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file
|
from utils import allowed_file
|
||||||
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'checkpoints')
|
||||||
|
|
||||||
def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None):
|
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."""
|
"""Build and return a prepared ComfyUI workflow dict for a checkpoint generation."""
|
||||||
@@ -95,26 +97,6 @@ def register_routes(app):
|
|||||||
existing_previews=existing_previews,
|
existing_previews=existing_previews,
|
||||||
extra_positive=extra_positive, extra_negative=extra_negative)
|
extra_positive=extra_positive, extra_negative=extra_negative)
|
||||||
|
|
||||||
@app.route('/checkpoint/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/checkpoint/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_checkpoint_image(slug):
|
def generate_checkpoint_image(slug):
|
||||||
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -145,52 +127,12 @@ def register_routes(app):
|
|||||||
return {'status': 'queued', 'job_id': job['id']}
|
return {'status': 'queued', 'job_id': job['id']}
|
||||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
return redirect(url_for('checkpoint_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/checkpoint/<path:slug>/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/<path:slug>/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'])
|
@app.route('/checkpoints/bulk_create', methods=['POST'])
|
||||||
def bulk_create_checkpoints():
|
def bulk_create_checkpoints():
|
||||||
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
|
checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints')
|
||||||
@@ -304,11 +246,3 @@ def register_routes(app):
|
|||||||
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
|
flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).')
|
||||||
return redirect(url_for('checkpoints_index'))
|
return redirect(url_for('checkpoints_index'))
|
||||||
|
|
||||||
@app.route('/checkpoint/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_checkpoint_favourite(slug):
|
|
||||||
ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404()
|
|
||||||
ckpt.is_favourite = not ckpt.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': ckpt.is_favourite}
|
|
||||||
return redirect(url_for('checkpoint_detail', slug=slug))
|
|
||||||
|
|||||||
@@ -3,23 +3,24 @@ import os
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look
|
from models import db, Character, Detailer, Action, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_detailers
|
from services.sync import sync_detailers
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
|
from utils import _WARDROBE_KEYS
|
||||||
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'detailers')
|
||||||
|
|
||||||
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):
|
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:
|
if character:
|
||||||
@@ -193,40 +194,11 @@ def register_routes(app):
|
|||||||
return redirect(url_for('detailer_detail', slug=slug))
|
return redirect(url_for('detailer_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('detailers/edit.html', detailer=detailer, loras=loras)
|
return render_template('detailers/edit.html', detailer=detailer, loras=loras)
|
||||||
|
|
||||||
@app.route('/detailer/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/detailer/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_detailer_image(slug):
|
def generate_detailer_image(slug):
|
||||||
detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404()
|
detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -277,49 +249,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('detailer_detail', slug=slug))
|
return redirect(url_for('detailer_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('detailer_detail', slug=slug))
|
return redirect(url_for('detailer_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/detailer/<path:slug>/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/<path:slug>/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/<path:slug>/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'])
|
@app.route('/detailers/bulk_create', methods=['POST'])
|
||||||
def bulk_create_detailers_from_loras():
|
def bulk_create_detailers_from_loras():
|
||||||
_s = Settings.query.first()
|
_s = Settings.query.first()
|
||||||
@@ -463,17 +398,9 @@ def register_routes(app):
|
|||||||
flash('Detailer created successfully!')
|
flash('Detailer created successfully!')
|
||||||
return redirect(url_for('detailer_detail', slug=safe_slug))
|
return redirect(url_for('detailer_detail', slug=safe_slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create detailer: {e}")
|
flash(f"Failed to create detailer: {e}")
|
||||||
return render_template('detailers/create.html', form_data=form_data)
|
return render_template('detailers/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('detailers/create.html', form_data=form_data)
|
return render_template('detailers/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/detailer/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_detailer_favourite(slug):
|
|
||||||
detailer = Detailer.query.filter_by(slug=slug).first_or_404()
|
|
||||||
detailer.is_favourite = not detailer.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': detailer.is_favourite}
|
|
||||||
return redirect(url_for('detailer_detail', slug=slug))
|
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ import os
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from models import db, Character, Look, Action, Checkpoint, Settings, Outfit
|
from models import db, Character, Look, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _dedup_tags
|
||||||
from services.sync import sync_looks
|
from services.sync import sync_looks
|
||||||
from services.file_io import get_available_loras, _count_look_assignments
|
from services.file_io import get_available_loras, _count_look_assignments
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
@@ -54,6 +53,7 @@ def _fix_look_lora_data(lora_data):
|
|||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'looks')
|
||||||
|
|
||||||
@app.route('/looks')
|
@app.route('/looks')
|
||||||
def looks_index():
|
def looks_index():
|
||||||
@@ -173,23 +173,6 @@ def register_routes(app):
|
|||||||
|
|
||||||
return render_template('looks/edit.html', look=look, characters=characters, loras=loras)
|
return render_template('looks/edit.html', look=look, characters=characters, loras=loras)
|
||||||
|
|
||||||
@app.route('/look/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/look/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_look_image(slug):
|
def generate_look_image(slug):
|
||||||
look = Look.query.filter_by(slug=slug).first_or_404()
|
look = Look.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -275,32 +258,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('look_detail', slug=slug))
|
return redirect(url_for('look_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('look_detail', slug=slug))
|
return redirect(url_for('look_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/look/<path:slug>/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/<path:slug>/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/<path:slug>/generate_character', methods=['POST'])
|
@app.route('/look/<path:slug>/generate_character', methods=['POST'])
|
||||||
def generate_character_from_look(slug):
|
def generate_character_from_look(slug):
|
||||||
"""Generate a character JSON using a look as the base."""
|
"""Generate a character JSON using a look as the base."""
|
||||||
@@ -426,23 +389,6 @@ Character ID: {character_slug}"""
|
|||||||
flash(f'Character "{character_name}" created from look!', 'success')
|
flash(f'Character "{character_name}" created from look!', 'success')
|
||||||
return redirect(url_for('detail', slug=character_slug))
|
return redirect(url_for('detail', slug=character_slug))
|
||||||
|
|
||||||
@app.route('/look/<path:slug>/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'])
|
@app.route('/look/create', methods=['GET', 'POST'])
|
||||||
def create_look():
|
def create_look():
|
||||||
characters = Character.query.order_by(Character.name).all()
|
characters = Character.query.order_by(Character.name).all()
|
||||||
@@ -499,25 +445,12 @@ Character ID: {character_slug}"""
|
|||||||
flash(f'Look "{name}" created!')
|
flash(f'Look "{name}" created!')
|
||||||
return redirect(url_for('look_detail', slug=slug))
|
return redirect(url_for('look_detail', slug=slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create look: {e}")
|
flash(f"Failed to create look: {e}")
|
||||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||||
|
|
||||||
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data)
|
||||||
|
|
||||||
@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'])
|
@app.route('/looks/bulk_create', methods=['POST'])
|
||||||
def bulk_create_looks_from_loras():
|
def bulk_create_looks_from_loras():
|
||||||
_s = Settings.query.first()
|
_s = Settings.query.first()
|
||||||
@@ -615,11 +548,3 @@ Character ID: {character_slug}"""
|
|||||||
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
|
flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).')
|
||||||
return redirect(url_for('looks_index'))
|
return redirect(url_for('looks_index'))
|
||||||
|
|
||||||
@app.route('/look/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_look_favourite(slug):
|
|
||||||
look = Look.query.filter_by(slug=slug).first_or_404()
|
|
||||||
look.is_favourite = not look.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': look.is_favourite}
|
|
||||||
return redirect(url_for('look_detail', slug=slug))
|
|
||||||
@@ -15,24 +15,13 @@ from services.sync import sync_outfits
|
|||||||
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
from services.file_io import get_available_loras, _count_outfit_lora_assignments
|
||||||
from utils import allowed_file, _LORA_DEFAULTS
|
from utils import allowed_file, _LORA_DEFAULTS
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'outfits')
|
||||||
@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')
|
@app.route('/outfits')
|
||||||
def outfits_index():
|
def outfits_index():
|
||||||
@@ -268,40 +257,11 @@ def register_routes(app):
|
|||||||
return redirect(url_for('outfit_detail', slug=slug))
|
return redirect(url_for('outfit_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('outfits/edit.html', outfit=outfit, loras=loras)
|
return render_template('outfits/edit.html', outfit=outfit, loras=loras)
|
||||||
|
|
||||||
@app.route('/outfit/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/outfit/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_outfit_image(slug):
|
def generate_outfit_image(slug):
|
||||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -406,24 +366,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('outfit_detail', slug=slug))
|
return redirect(url_for('outfit_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('outfit_detail', slug=slug))
|
return redirect(url_for('outfit_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/outfit/<path:slug>/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'])
|
@app.route('/outfit/create', methods=['GET', 'POST'])
|
||||||
def create_outfit():
|
def create_outfit():
|
||||||
form_data = {}
|
form_data = {}
|
||||||
@@ -496,7 +444,7 @@ def register_routes(app):
|
|||||||
outfit_data['tags'] = []
|
outfit_data['tags'] = []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LLM error: {e}")
|
logger.exception("LLM error: %s", e)
|
||||||
flash(f"Failed to generate outfit profile: {e}")
|
flash(f"Failed to generate outfit profile: {e}")
|
||||||
return render_template('outfits/create.html', form_data=form_data)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
else:
|
else:
|
||||||
@@ -542,92 +490,10 @@ def register_routes(app):
|
|||||||
return redirect(url_for('outfit_detail', slug=safe_slug))
|
return redirect(url_for('outfit_detail', slug=safe_slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create outfit: {e}")
|
flash(f"Failed to create outfit: {e}")
|
||||||
return render_template('outfits/create.html', form_data=form_data)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('outfits/create.html', form_data=form_data)
|
return render_template('outfits/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/outfit/<path:slug>/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/<path:slug>/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/<path:slug>/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}
|
|
||||||
|
|
||||||
@app.route('/outfit/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_outfit_favourite(slug):
|
|
||||||
outfit = Outfit.query.filter_by(slug=slug).first_or_404()
|
|
||||||
outfit.is_favourite = not outfit.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': outfit.is_favourite}
|
|
||||||
return redirect(url_for('outfit_detail', slug=slug))
|
|
||||||
|
|||||||
@@ -2,23 +2,19 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
||||||
from werkzeug.utils import secure_filename
|
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look
|
||||||
from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background
|
from services.sync import sync_presets
|
||||||
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.generation import generate_from_preset
|
from services.generation import generate_from_preset
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file
|
from routes.shared import register_common_routes
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'presets')
|
||||||
|
|
||||||
@app.route('/presets')
|
@app.route('/presets')
|
||||||
def presets_index():
|
def presets_index():
|
||||||
@@ -79,37 +75,6 @@ def register_routes(app):
|
|||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('preset_detail', slug=slug))
|
return redirect(url_for('preset_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/preset/<path:slug>/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/<path:slug>/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/<path:slug>/edit', methods=['GET', 'POST'])
|
@app.route('/preset/<path:slug>/edit', methods=['GET', 'POST'])
|
||||||
def edit_preset(slug):
|
def edit_preset(slug):
|
||||||
preset = Preset.query.filter_by(slug=slug).first_or_404()
|
preset = Preset.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -196,51 +161,6 @@ def register_routes(app):
|
|||||||
styles=styles, scenes=scenes, detailers=detailers,
|
styles=styles, scenes=scenes, detailers=detailers,
|
||||||
looks=looks, checkpoints=checkpoints)
|
looks=looks, checkpoints=checkpoints)
|
||||||
|
|
||||||
@app.route('/preset/<path:slug>/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/<path:slug>/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'])
|
@app.route('/presets/rescan', methods=['POST'])
|
||||||
def rescan_presets():
|
def rescan_presets():
|
||||||
sync_presets()
|
sync_presets()
|
||||||
@@ -322,7 +242,3 @@ def register_routes(app):
|
|||||||
|
|
||||||
return render_template('presets/create.html', form_data=form_data)
|
return render_template('presets/create.html', form_data=form_data)
|
||||||
|
|
||||||
@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]}
|
|
||||||
|
|||||||
145
routes/scenes.py
145
routes/scenes.py
@@ -3,36 +3,24 @@ import os
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look
|
from models import db, Character, Scene, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields
|
||||||
from services.sync import sync_scenes
|
from services.sync import sync_scenes
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS
|
from routes.shared import register_common_routes
|
||||||
|
from utils import _WARDROBE_KEYS
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'scenes')
|
||||||
@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')
|
@app.route('/scenes')
|
||||||
def scenes_index():
|
def scenes_index():
|
||||||
@@ -147,40 +135,11 @@ def register_routes(app):
|
|||||||
return redirect(url_for('scene_detail', slug=slug))
|
return redirect(url_for('scene_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('scenes/edit.html', scene=scene, loras=loras)
|
return render_template('scenes/edit.html', scene=scene, loras=loras)
|
||||||
|
|
||||||
@app.route('/scene/<path:slug>/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):
|
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:
|
if character:
|
||||||
combined_data = character.data.copy()
|
combined_data = character.data.copy()
|
||||||
@@ -306,33 +265,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('scene_detail', slug=slug))
|
return redirect(url_for('scene_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('scene_detail', slug=slug))
|
return redirect(url_for('scene_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/scene/<path:slug>/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/<path:slug>/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'])
|
@app.route('/scenes/bulk_create', methods=['POST'])
|
||||||
def bulk_create_scenes_from_loras():
|
def bulk_create_scenes_from_loras():
|
||||||
_s = Settings.query.first()
|
_s = Settings.query.first()
|
||||||
@@ -483,74 +421,9 @@ def register_routes(app):
|
|||||||
flash('Scene created successfully!')
|
flash('Scene created successfully!')
|
||||||
return redirect(url_for('scene_detail', slug=safe_slug))
|
return redirect(url_for('scene_detail', slug=safe_slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create scene: {e}")
|
flash(f"Failed to create scene: {e}")
|
||||||
return render_template('scenes/create.html', form_data=form_data)
|
return render_template('scenes/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('scenes/create.html', form_data=form_data)
|
return render_template('scenes/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/scene/<path:slug>/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/<path:slug>/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}
|
|
||||||
|
|
||||||
@app.route('/scene/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_scene_favourite(slug):
|
|
||||||
scene = Scene.query.filter_by(slug=slug).first_or_404()
|
|
||||||
scene.is_favourite = not scene.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': scene.is_favourite}
|
|
||||||
return redirect(url_for('scene_detail', slug=slug))
|
|
||||||
|
|||||||
422
routes/shared.py
Normal file
422
routes/shared.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Shared route factory functions for common patterns across all resource categories.
|
||||||
|
|
||||||
|
Each factory registers a route on the Flask app using the closure pattern,
|
||||||
|
preserving endpoint names for url_for() compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import current_app, flash, redirect, request, url_for
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from models import db
|
||||||
|
from utils import allowed_file
|
||||||
|
|
||||||
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Category configuration registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each entry maps a category name to its metadata.
|
||||||
|
# 'url_prefix': the URL segment (e.g. 'outfit' → /outfit/<slug>/...)
|
||||||
|
# 'detail_endpoint': the Flask endpoint name for the detail page
|
||||||
|
# 'config_dir': the app.config key for the JSON data directory
|
||||||
|
# 'category_folder': subfolder under static/uploads/
|
||||||
|
# 'id_field': JSON key for entity ID
|
||||||
|
# 'name_field': JSON key for display name
|
||||||
|
|
||||||
|
CATEGORY_CONFIG = {
|
||||||
|
'characters': {
|
||||||
|
'model': None, # Set at import time to avoid circular imports
|
||||||
|
'url_prefix': 'character',
|
||||||
|
'detail_endpoint': 'detail',
|
||||||
|
'config_dir': 'CHARACTERS_DIR',
|
||||||
|
'category_folder': 'characters',
|
||||||
|
'id_field': 'character_id',
|
||||||
|
'name_field': 'character_name',
|
||||||
|
# Characters use unprefixed endpoint names
|
||||||
|
'endpoint_prefix': '',
|
||||||
|
},
|
||||||
|
'outfits': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'outfit',
|
||||||
|
'detail_endpoint': 'outfit_detail',
|
||||||
|
'config_dir': 'CLOTHING_DIR',
|
||||||
|
'category_folder': 'outfits',
|
||||||
|
'id_field': 'outfit_id',
|
||||||
|
'name_field': 'outfit_name',
|
||||||
|
'endpoint_prefix': 'outfit_',
|
||||||
|
},
|
||||||
|
'actions': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'action',
|
||||||
|
'detail_endpoint': 'action_detail',
|
||||||
|
'config_dir': 'ACTIONS_DIR',
|
||||||
|
'category_folder': 'actions',
|
||||||
|
'id_field': 'action_id',
|
||||||
|
'name_field': 'action_name',
|
||||||
|
'endpoint_prefix': 'action_',
|
||||||
|
},
|
||||||
|
'styles': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'style',
|
||||||
|
'detail_endpoint': 'style_detail',
|
||||||
|
'config_dir': 'STYLES_DIR',
|
||||||
|
'category_folder': 'styles',
|
||||||
|
'id_field': 'style_id',
|
||||||
|
'name_field': 'style_name',
|
||||||
|
'endpoint_prefix': 'style_',
|
||||||
|
},
|
||||||
|
'scenes': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'scene',
|
||||||
|
'detail_endpoint': 'scene_detail',
|
||||||
|
'config_dir': 'SCENES_DIR',
|
||||||
|
'category_folder': 'scenes',
|
||||||
|
'id_field': 'scene_id',
|
||||||
|
'name_field': 'scene_name',
|
||||||
|
'endpoint_prefix': 'scene_',
|
||||||
|
},
|
||||||
|
'detailers': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'detailer',
|
||||||
|
'detail_endpoint': 'detailer_detail',
|
||||||
|
'config_dir': 'DETAILERS_DIR',
|
||||||
|
'category_folder': 'detailers',
|
||||||
|
'id_field': 'detailer_id',
|
||||||
|
'name_field': 'detailer_name',
|
||||||
|
'endpoint_prefix': 'detailer_',
|
||||||
|
},
|
||||||
|
'looks': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'look',
|
||||||
|
'detail_endpoint': 'look_detail',
|
||||||
|
'config_dir': 'LOOKS_DIR',
|
||||||
|
'category_folder': 'looks',
|
||||||
|
'id_field': 'look_id',
|
||||||
|
'name_field': 'look_name',
|
||||||
|
'endpoint_prefix': 'look_',
|
||||||
|
},
|
||||||
|
'checkpoints': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'checkpoint',
|
||||||
|
'detail_endpoint': 'checkpoint_detail',
|
||||||
|
'config_dir': 'CHECKPOINTS_DIR',
|
||||||
|
'category_folder': 'checkpoints',
|
||||||
|
'id_field': 'checkpoint_path',
|
||||||
|
'name_field': 'checkpoint_name',
|
||||||
|
'endpoint_prefix': 'checkpoint_',
|
||||||
|
},
|
||||||
|
'presets': {
|
||||||
|
'model': None,
|
||||||
|
'url_prefix': 'preset',
|
||||||
|
'detail_endpoint': 'preset_detail',
|
||||||
|
'config_dir': 'PRESETS_DIR',
|
||||||
|
'category_folder': 'presets',
|
||||||
|
'id_field': 'preset_id',
|
||||||
|
'name_field': 'preset_name',
|
||||||
|
'endpoint_prefix': 'preset_',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _init_models():
|
||||||
|
"""Lazily populate model references to avoid circular imports."""
|
||||||
|
from models import (Action, Character, Checkpoint, Detailer, Look,
|
||||||
|
Outfit, Preset, Scene, Style)
|
||||||
|
CATEGORY_CONFIG['characters']['model'] = Character
|
||||||
|
CATEGORY_CONFIG['outfits']['model'] = Outfit
|
||||||
|
CATEGORY_CONFIG['actions']['model'] = Action
|
||||||
|
CATEGORY_CONFIG['styles']['model'] = Style
|
||||||
|
CATEGORY_CONFIG['scenes']['model'] = Scene
|
||||||
|
CATEGORY_CONFIG['detailers']['model'] = Detailer
|
||||||
|
CATEGORY_CONFIG['looks']['model'] = Look
|
||||||
|
CATEGORY_CONFIG['checkpoints']['model'] = Checkpoint
|
||||||
|
CATEGORY_CONFIG['presets']['model'] = Preset
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config(category):
|
||||||
|
"""Get config for a category, initializing models if needed."""
|
||||||
|
cfg = CATEGORY_CONFIG[category]
|
||||||
|
if cfg['model'] is None:
|
||||||
|
_init_models()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Factory functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_favourite_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/favourite toggle route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
detail_ep = cfg['detail_endpoint']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
|
||||||
|
# Characters use 'toggle_character_favourite', others use 'toggle_{prefix}_favourite'
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'toggle_character_favourite'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'toggle_{prefix}_favourite'
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/favourite', methods=['POST'],
|
||||||
|
endpoint=endpoint_name)
|
||||||
|
def favourite_toggle(slug):
|
||||||
|
entity = Model.query.filter_by(slug=slug).first_or_404()
|
||||||
|
entity.is_favourite = not entity.is_favourite
|
||||||
|
db.session.commit()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return {'success': True, 'is_favourite': entity.is_favourite}
|
||||||
|
return redirect(url_for(detail_ep, slug=slug))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_upload_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/upload image route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
detail_ep = cfg['detail_endpoint']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
folder = cfg['category_folder']
|
||||||
|
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'upload_image'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'upload_{prefix}_image'
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/upload', methods=['POST'],
|
||||||
|
endpoint=endpoint_name)
|
||||||
|
def upload_image(slug):
|
||||||
|
entity = Model.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):
|
||||||
|
entity_folder = os.path.join(
|
||||||
|
current_app.config['UPLOAD_FOLDER'], f"{folder}/{slug}")
|
||||||
|
os.makedirs(entity_folder, exist_ok=True)
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
file_path = os.path.join(entity_folder, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
entity.image_path = f"{folder}/{slug}/{filename}"
|
||||||
|
db.session.commit()
|
||||||
|
flash('Image uploaded successfully!')
|
||||||
|
|
||||||
|
return redirect(url_for(detail_ep, slug=slug))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_replace_cover_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/replace_cover_from_preview route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
detail_ep = cfg['detail_endpoint']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'replace_cover_from_preview'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'replace_{prefix}_cover_from_preview'
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/replace_cover_from_preview',
|
||||||
|
methods=['POST'], endpoint=endpoint_name)
|
||||||
|
def replace_cover(slug):
|
||||||
|
entity = Model.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)):
|
||||||
|
entity.image_path = preview_path
|
||||||
|
db.session.commit()
|
||||||
|
flash('Cover image updated!')
|
||||||
|
else:
|
||||||
|
flash('No valid preview image selected.', 'error')
|
||||||
|
return redirect(url_for(detail_ep, slug=slug))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_save_defaults_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/save_defaults route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
detail_ep = cfg['detail_endpoint']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
category = cfg['category_folder']
|
||||||
|
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'save_defaults'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'save_{prefix}_defaults'
|
||||||
|
|
||||||
|
# Display name for the flash message
|
||||||
|
display = category.rstrip('s')
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/save_defaults', methods=['POST'],
|
||||||
|
endpoint=endpoint_name)
|
||||||
|
def save_defaults(slug):
|
||||||
|
entity = Model.query.filter_by(slug=slug).first_or_404()
|
||||||
|
selected_fields = request.form.getlist('include_field')
|
||||||
|
entity.default_fields = selected_fields
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Default prompt selection saved for this {display}!')
|
||||||
|
return redirect(url_for(detail_ep, slug=slug))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_clone_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/clone route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
detail_ep = cfg['detail_endpoint']
|
||||||
|
config_dir = cfg['config_dir']
|
||||||
|
id_field = cfg['id_field']
|
||||||
|
name_field = cfg['name_field']
|
||||||
|
|
||||||
|
endpoint_name = f'clone_{prefix}'
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/clone', methods=['POST'],
|
||||||
|
endpoint=endpoint_name)
|
||||||
|
def clone_entity(slug):
|
||||||
|
entity = Model.query.filter_by(slug=slug).first_or_404()
|
||||||
|
|
||||||
|
base_id = getattr(entity, id_field, None) or entity.data.get(id_field)
|
||||||
|
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(
|
||||||
|
current_app.config[config_dir], new_filename)
|
||||||
|
if not os.path.exists(new_path):
|
||||||
|
break
|
||||||
|
next_num += 1
|
||||||
|
|
||||||
|
new_data = dict(entity.data)
|
||||||
|
new_data[id_field] = new_id
|
||||||
|
new_data[name_field] = f"{entity.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)
|
||||||
|
kwargs = {
|
||||||
|
id_field: new_id,
|
||||||
|
'slug': new_slug,
|
||||||
|
'filename': new_filename,
|
||||||
|
'name': new_data[name_field],
|
||||||
|
'data': new_data,
|
||||||
|
}
|
||||||
|
new_entity = Model(**kwargs)
|
||||||
|
db.session.add(new_entity)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Cloned as "{new_id}"!')
|
||||||
|
return redirect(url_for(detail_ep, slug=new_slug))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_save_json_route(app, cfg):
|
||||||
|
"""Register POST /<prefix>/<slug>/save_json route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
prefix = cfg['url_prefix']
|
||||||
|
config_dir = cfg['config_dir']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'save_character_json'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'save_{prefix}_json'
|
||||||
|
|
||||||
|
@app.route(f'/{prefix}/<path:slug>/save_json', methods=['POST'],
|
||||||
|
endpoint=endpoint_name)
|
||||||
|
def save_json(slug):
|
||||||
|
entity = Model.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
|
||||||
|
entity.data = new_data
|
||||||
|
flag_modified(entity, 'data')
|
||||||
|
db.session.commit()
|
||||||
|
if entity.filename:
|
||||||
|
file_path = os.path.join(
|
||||||
|
current_app.config[config_dir], entity.filename)
|
||||||
|
with open(file_path, 'w') as f:
|
||||||
|
json.dump(new_data, f, indent=2)
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_get_missing_route(app, cfg):
|
||||||
|
"""Register GET /get_missing_<category> route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
category = cfg['category_folder']
|
||||||
|
|
||||||
|
endpoint_name = f'get_missing_{category}'
|
||||||
|
|
||||||
|
@app.route(f'/get_missing_{category}', endpoint=endpoint_name)
|
||||||
|
def get_missing():
|
||||||
|
missing = Model.query.filter(
|
||||||
|
(Model.image_path == None) | (Model.image_path == '')
|
||||||
|
).order_by(Model.name).all()
|
||||||
|
return {'missing': [{'slug': e.slug, 'name': e.name} for e in missing]}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_clear_covers_route(app, cfg):
|
||||||
|
"""Register POST /clear_all_<category>_covers route."""
|
||||||
|
Model = cfg['model']
|
||||||
|
category = cfg['category_folder']
|
||||||
|
ep_prefix = cfg['endpoint_prefix']
|
||||||
|
|
||||||
|
# Characters use 'clear_all_covers', others use 'clear_all_{category}_covers'
|
||||||
|
if ep_prefix == '':
|
||||||
|
endpoint_name = 'clear_all_covers'
|
||||||
|
url = '/clear_all_covers'
|
||||||
|
else:
|
||||||
|
endpoint_name = f'clear_all_{category.rstrip("s")}_covers'
|
||||||
|
url = f'/clear_all_{category.rstrip("s")}_covers'
|
||||||
|
|
||||||
|
@app.route(url, methods=['POST'], endpoint=endpoint_name)
|
||||||
|
def clear_covers():
|
||||||
|
entities = Model.query.all()
|
||||||
|
for entity in entities:
|
||||||
|
entity.image_path = None
|
||||||
|
db.session.commit()
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main registration function
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_common_routes(app, category):
|
||||||
|
"""Register all common routes for a category.
|
||||||
|
|
||||||
|
Call this from each route module's register_routes(app) function.
|
||||||
|
"""
|
||||||
|
cfg = _get_config(category)
|
||||||
|
|
||||||
|
_register_favourite_route(app, cfg)
|
||||||
|
_register_upload_route(app, cfg)
|
||||||
|
_register_replace_cover_route(app, cfg)
|
||||||
|
_register_save_defaults_route(app, cfg)
|
||||||
|
_register_clone_route(app, cfg)
|
||||||
|
_register_save_json_route(app, cfg)
|
||||||
|
_register_get_missing_route(app, cfg)
|
||||||
|
_register_clear_covers_route(app, cfg)
|
||||||
@@ -263,7 +263,7 @@ def register_routes(app):
|
|||||||
else:
|
else:
|
||||||
character = None
|
character = None
|
||||||
|
|
||||||
print(f"[Strengths] char_slug={char_slug!r} → character={character.slug if character else 'none'}")
|
logger.debug("Strengths: char_slug=%r -> character=%s", char_slug, character.slug if character else 'none')
|
||||||
|
|
||||||
action_obj = None
|
action_obj = None
|
||||||
extra_positive = ''
|
extra_positive = ''
|
||||||
@@ -274,7 +274,7 @@ def register_routes(app):
|
|||||||
action_obj = Action.query.filter_by(slug=action_slug).first()
|
action_obj = Action.query.filter_by(slug=action_slug).first()
|
||||||
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
|
extra_positive = session.get(f'extra_pos_detailer_{slug}', '')
|
||||||
extra_negative = session.get(f'extra_neg_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)}")
|
logger.debug("Strengths: detailer session -- char=%s, action=%s, extra_pos=%s, extra_neg=%s", char_slug, action_slug, bool(extra_positive), bool(extra_negative))
|
||||||
|
|
||||||
prompts = _build_strengths_prompts(category, entity, character,
|
prompts = _build_strengths_prompts(category, entity, character,
|
||||||
action=action_obj, extra_positive=extra_positive)
|
action=action_obj, extra_positive=extra_positive)
|
||||||
@@ -321,7 +321,7 @@ def register_routes(app):
|
|||||||
return {'status': 'queued', 'job_id': job['id']}
|
return {'status': 'queued', 'job_id': job['id']}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Strengths] generate error: {e}")
|
logger.exception("Strengths generate error: %s", e)
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
|
|
||||||
@app.route('/strengths/<category>/<path:slug>/list')
|
@app.route('/strengths/<category>/<path:slug>/list')
|
||||||
|
|||||||
157
routes/styles.py
157
routes/styles.py
@@ -4,23 +4,24 @@ import re
|
|||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import render_template, request, redirect, url_for, flash, session, current_app
|
from flask import render_template, request, redirect, url_for, flash, session
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from models import db, Character, Style, Detailer, Settings
|
from models import db, Character, Style, Settings
|
||||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||||
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task
|
||||||
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background
|
||||||
from services.sync import sync_styles
|
from services.sync import sync_styles
|
||||||
from services.file_io import get_available_loras
|
from services.file_io import get_available_loras
|
||||||
from services.llm import load_prompt, call_llm
|
from services.llm import load_prompt, call_llm
|
||||||
from utils import allowed_file, _WARDROBE_KEYS
|
from routes.shared import register_common_routes
|
||||||
|
from utils import _WARDROBE_KEYS
|
||||||
|
|
||||||
logger = logging.getLogger('gaze')
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
|
register_common_routes(app, 'styles')
|
||||||
|
|
||||||
def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None):
|
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."""
|
"""Build and return a prepared ComfyUI workflow dict for a style generation."""
|
||||||
@@ -188,40 +189,11 @@ def register_routes(app):
|
|||||||
return redirect(url_for('style_detail', slug=slug))
|
return redirect(url_for('style_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Edit error: {e}")
|
logger.exception("Edit error: %s", e)
|
||||||
flash(f"Error saving changes: {str(e)}")
|
flash(f"Error saving changes: {str(e)}")
|
||||||
|
|
||||||
return render_template('styles/edit.html', style=style, loras=loras)
|
return render_template('styles/edit.html', style=style, loras=loras)
|
||||||
|
|
||||||
@app.route('/style/<path:slug>/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/<path:slug>/generate', methods=['POST'])
|
@app.route('/style/<path:slug>/generate', methods=['POST'])
|
||||||
def generate_style_image(slug):
|
def generate_style_image(slug):
|
||||||
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
||||||
@@ -269,59 +241,12 @@ def register_routes(app):
|
|||||||
return redirect(url_for('style_detail', slug=slug))
|
return redirect(url_for('style_detail', slug=slug))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Generation error: {e}")
|
logger.exception("Generation error: %s", e)
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
return {'error': str(e)}, 500
|
return {'error': str(e)}, 500
|
||||||
flash(f"Error during generation: {str(e)}")
|
flash(f"Error during generation: {str(e)}")
|
||||||
return redirect(url_for('style_detail', slug=slug))
|
return redirect(url_for('style_detail', slug=slug))
|
||||||
|
|
||||||
@app.route('/style/<path:slug>/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/<path:slug>/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'])
|
@app.route('/styles/generate_missing', methods=['POST'])
|
||||||
def generate_missing_styles():
|
def generate_missing_styles():
|
||||||
missing = Style.query.filter(
|
missing = Style.query.filter(
|
||||||
@@ -347,7 +272,7 @@ def register_routes(app):
|
|||||||
_make_finalize('styles', style_obj.slug, Style))
|
_make_finalize('styles', style_obj.slug, Style))
|
||||||
enqueued += 1
|
enqueued += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error queuing cover generation for style {style_obj.name}: {e}")
|
logger.exception("Error queuing cover generation for style %s: %s", style_obj.name, e)
|
||||||
|
|
||||||
flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.")
|
flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.")
|
||||||
return redirect(url_for('styles_index'))
|
return redirect(url_for('styles_index'))
|
||||||
@@ -511,73 +436,9 @@ def register_routes(app):
|
|||||||
flash('Style created successfully!')
|
flash('Style created successfully!')
|
||||||
return redirect(url_for('style_detail', slug=safe_slug))
|
return redirect(url_for('style_detail', slug=safe_slug))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Save error: {e}")
|
logger.exception("Save error: %s", e)
|
||||||
flash(f"Failed to create style: {e}")
|
flash(f"Failed to create style: {e}")
|
||||||
return render_template('styles/create.html', form_data=form_data)
|
return render_template('styles/create.html', form_data=form_data)
|
||||||
|
|
||||||
return render_template('styles/create.html', form_data=form_data)
|
return render_template('styles/create.html', form_data=form_data)
|
||||||
|
|
||||||
@app.route('/style/<path:slug>/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/<path:slug>/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}
|
|
||||||
|
|
||||||
@app.route('/style/<path:slug>/favourite', methods=['POST'])
|
|
||||||
def toggle_style_favourite(slug):
|
|
||||||
style_obj = Style.query.filter_by(slug=slug).first_or_404()
|
|
||||||
style_obj.is_favourite = not style_obj.is_favourite
|
|
||||||
db.session.commit()
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return {'success': True, 'is_favourite': style_obj.is_favourite}
|
|
||||||
return redirect(url_for('style_detail', slug=slug))
|
|
||||||
|
|||||||
@@ -272,8 +272,7 @@ def _make_finalize(category, slug, db_model_class=None, action=None, metadata=No
|
|||||||
logger.debug("=" * 80)
|
logger.debug("=" * 80)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning("FINALIZE - No images found in outputs!")
|
raise RuntimeError("No images found in ComfyUI outputs")
|
||||||
logger.debug("=" * 80)
|
|
||||||
return _finalize
|
return _finalize
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from flask import has_request_context, request as flask_request
|
from flask import has_request_context, request as flask_request
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
from models import Settings
|
from models import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
DANBOORU_TOOLS = [
|
DANBOORU_TOOLS = [
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -73,7 +76,7 @@ def call_mcp_tool(name, arguments):
|
|||||||
try:
|
try:
|
||||||
return asyncio.run(_run_mcp_tool(name, arguments))
|
return asyncio.run(_run_mcp_tool(name, arguments))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"MCP Tool Error: {e}")
|
logger.error("MCP Tool Error: %s", e)
|
||||||
return json.dumps({"error": str(e)})
|
return json.dumps({"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +98,7 @@ def call_character_mcp_tool(name, arguments):
|
|||||||
try:
|
try:
|
||||||
return asyncio.run(_run_character_mcp_tool(name, arguments))
|
return asyncio.run(_run_character_mcp_tool(name, arguments))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Character MCP Tool Error: {e}")
|
logger.error("Character MCP Tool Error: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
|
|
||||||
# If 400 Bad Request and we were using tools, try once without tools
|
# If 400 Bad Request and we were using tools, try once without tools
|
||||||
if response.status_code == 400 and use_tools:
|
if response.status_code == 400 and use_tools:
|
||||||
print(f"LLM Provider {settings.llm_provider} rejected tools. Retrying without tool calling...")
|
logger.warning("LLM Provider %s rejected tools. Retrying without tool calling...", settings.llm_provider)
|
||||||
use_tools = False
|
use_tools = False
|
||||||
max_turns += 1 # Reset turn for the retry
|
max_turns += 1 # Reset turn for the retry
|
||||||
continue
|
continue
|
||||||
@@ -186,7 +189,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
for tool_call in message['tool_calls']:
|
for tool_call in message['tool_calls']:
|
||||||
name = tool_call['function']['name']
|
name = tool_call['function']['name']
|
||||||
args = json.loads(tool_call['function']['arguments'])
|
args = json.loads(tool_call['function']['arguments'])
|
||||||
print(f"Executing MCP tool: {name}({args})")
|
logger.debug("Executing MCP tool: %s(%s)", name, args)
|
||||||
tool_result = call_mcp_tool(name, args)
|
tool_result = call_mcp_tool(name, args)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
@@ -195,7 +198,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
"content": tool_result
|
"content": tool_result
|
||||||
})
|
})
|
||||||
if tool_turns_remaining <= 0:
|
if tool_turns_remaining <= 0:
|
||||||
print("Tool turn limit reached — next request will not offer tools")
|
logger.warning("Tool turn limit reached — next request will not offer tools")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return message['content']
|
return message['content']
|
||||||
@@ -209,7 +212,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
raw = ""
|
raw = ""
|
||||||
try: raw = response.text[:500]
|
try: raw = response.text[:500]
|
||||||
except: pass
|
except: pass
|
||||||
print(f"Unexpected LLM response format (key={e}). Raw response: {raw}")
|
logger.warning("Unexpected LLM response format (key=%s). Raw response: %s", e, raw)
|
||||||
if format_retries > 0:
|
if format_retries > 0:
|
||||||
format_retries -= 1
|
format_retries -= 1
|
||||||
max_turns += 1 # don't burn a turn on a format error
|
max_turns += 1 # don't burn a turn on a format error
|
||||||
@@ -222,7 +225,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."):
|
|||||||
"Do not include any explanation or markdown — only the raw JSON object."
|
"Do not include any explanation or markdown — only the raw JSON object."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
print(f"Retrying after format error ({format_retries} retries left)…")
|
logger.info("Retrying after format error (%d retries left)…", format_retries)
|
||||||
continue
|
continue
|
||||||
raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e
|
raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger('gaze')
|
||||||
|
|
||||||
# Path to the MCP docker-compose projects, relative to the main app file.
|
# 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_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_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp')
|
||||||
@@ -19,28 +22,28 @@ def _ensure_mcp_repo():
|
|||||||
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
|
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
if not os.path.isdir(MCP_COMPOSE_DIR):
|
if not os.path.isdir(MCP_COMPOSE_DIR):
|
||||||
print(f'Cloning danbooru-mcp from {MCP_REPO_URL} …')
|
logger.info('Cloning danbooru-mcp from %s …', MCP_REPO_URL)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR],
|
['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR],
|
||||||
timeout=120, check=True,
|
timeout=120, check=True,
|
||||||
)
|
)
|
||||||
print('danbooru-mcp cloned successfully.')
|
logger.info('danbooru-mcp cloned successfully.')
|
||||||
else:
|
else:
|
||||||
print('Updating danbooru-mcp via git pull …')
|
logger.info('Updating danbooru-mcp via git pull …')
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['git', 'pull'],
|
['git', 'pull'],
|
||||||
cwd=MCP_COMPOSE_DIR,
|
cwd=MCP_COMPOSE_DIR,
|
||||||
timeout=60, check=True,
|
timeout=60, check=True,
|
||||||
)
|
)
|
||||||
print('danbooru-mcp updated.')
|
logger.info('danbooru-mcp updated.')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('WARNING: git not found on PATH — danbooru-mcp repo will not be cloned/updated.')
|
logger.warning('git not found on PATH — danbooru-mcp repo will not be cloned/updated.')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f'WARNING: git operation failed for danbooru-mcp: {e}')
|
logger.warning('git operation failed for danbooru-mcp: %s', e)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('WARNING: git timed out while cloning/updating danbooru-mcp.')
|
logger.warning('git timed out while cloning/updating danbooru-mcp.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'WARNING: Could not clone/update danbooru-mcp repo: {e}')
|
logger.warning('Could not clone/update danbooru-mcp repo: %s', e)
|
||||||
|
|
||||||
|
|
||||||
def ensure_mcp_server_running():
|
def ensure_mcp_server_running():
|
||||||
@@ -55,7 +58,7 @@ def ensure_mcp_server_running():
|
|||||||
danbooru-mcp service is managed by compose instead).
|
danbooru-mcp service is managed by compose instead).
|
||||||
"""
|
"""
|
||||||
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
|
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
|
||||||
print('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.')
|
logger.info('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.')
|
||||||
return
|
return
|
||||||
_ensure_mcp_repo()
|
_ensure_mcp_repo()
|
||||||
try:
|
try:
|
||||||
@@ -64,22 +67,22 @@ def ensure_mcp_server_running():
|
|||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
if 'danbooru-mcp' in result.stdout:
|
if 'danbooru-mcp' in result.stdout:
|
||||||
print('danbooru-mcp container already running.')
|
logger.info('danbooru-mcp container already running.')
|
||||||
return
|
return
|
||||||
# Container not running — start it via docker compose
|
# Container not running — start it via docker compose
|
||||||
print('Starting danbooru-mcp container via docker compose …')
|
logger.info('Starting danbooru-mcp container via docker compose …')
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['docker', 'compose', 'up', '-d'],
|
['docker', 'compose', 'up', '-d'],
|
||||||
cwd=MCP_COMPOSE_DIR,
|
cwd=MCP_COMPOSE_DIR,
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
print('danbooru-mcp container started.')
|
logger.info('danbooru-mcp container started.')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('WARNING: docker not found on PATH — danbooru-mcp will not be started automatically.')
|
logger.warning('docker not found on PATH — danbooru-mcp will not be started automatically.')
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('WARNING: docker timed out while starting danbooru-mcp.')
|
logger.warning('docker timed out while starting danbooru-mcp.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'WARNING: Could not ensure danbooru-mcp is running: {e}')
|
logger.warning('Could not ensure danbooru-mcp is running: %s', e)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_character_mcp_repo():
|
def _ensure_character_mcp_repo():
|
||||||
@@ -92,28 +95,28 @@ def _ensure_character_mcp_repo():
|
|||||||
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
|
os.makedirs(MCP_TOOLS_DIR, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
if not os.path.isdir(CHAR_MCP_COMPOSE_DIR):
|
if not os.path.isdir(CHAR_MCP_COMPOSE_DIR):
|
||||||
print(f'Cloning character-mcp from {CHAR_MCP_REPO_URL} …')
|
logger.info('Cloning character-mcp from %s …', CHAR_MCP_REPO_URL)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['git', 'clone', CHAR_MCP_REPO_URL, CHAR_MCP_COMPOSE_DIR],
|
['git', 'clone', CHAR_MCP_REPO_URL, CHAR_MCP_COMPOSE_DIR],
|
||||||
timeout=120, check=True,
|
timeout=120, check=True,
|
||||||
)
|
)
|
||||||
print('character-mcp cloned successfully.')
|
logger.info('character-mcp cloned successfully.')
|
||||||
else:
|
else:
|
||||||
print('Updating character-mcp via git pull …')
|
logger.info('Updating character-mcp via git pull …')
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['git', 'pull'],
|
['git', 'pull'],
|
||||||
cwd=CHAR_MCP_COMPOSE_DIR,
|
cwd=CHAR_MCP_COMPOSE_DIR,
|
||||||
timeout=60, check=True,
|
timeout=60, check=True,
|
||||||
)
|
)
|
||||||
print('character-mcp updated.')
|
logger.info('character-mcp updated.')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('WARNING: git not found on PATH — character-mcp repo will not be cloned/updated.')
|
logger.warning('git not found on PATH — character-mcp repo will not be cloned/updated.')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f'WARNING: git operation failed for character-mcp: {e}')
|
logger.warning('git operation failed for character-mcp: %s', e)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('WARNING: git timed out while cloning/updating character-mcp.')
|
logger.warning('git timed out while cloning/updating character-mcp.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'WARNING: Could not clone/update character-mcp repo: {e}')
|
logger.warning('Could not clone/update character-mcp repo: %s', e)
|
||||||
|
|
||||||
|
|
||||||
def ensure_character_mcp_server_running():
|
def ensure_character_mcp_server_running():
|
||||||
@@ -128,7 +131,7 @@ def ensure_character_mcp_server_running():
|
|||||||
character-mcp service is managed by compose instead).
|
character-mcp service is managed by compose instead).
|
||||||
"""
|
"""
|
||||||
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
|
if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true':
|
||||||
print('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.')
|
logger.info('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.')
|
||||||
return
|
return
|
||||||
_ensure_character_mcp_repo()
|
_ensure_character_mcp_repo()
|
||||||
try:
|
try:
|
||||||
@@ -137,19 +140,19 @@ def ensure_character_mcp_server_running():
|
|||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
if 'character-mcp' in result.stdout:
|
if 'character-mcp' in result.stdout:
|
||||||
print('character-mcp container already running.')
|
logger.info('character-mcp container already running.')
|
||||||
return
|
return
|
||||||
# Container not running — start it via docker compose
|
# Container not running — start it via docker compose
|
||||||
print('Starting character-mcp container via docker compose …')
|
logger.info('Starting character-mcp container via docker compose …')
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
['docker', 'compose', 'up', '-d'],
|
['docker', 'compose', 'up', '-d'],
|
||||||
cwd=CHAR_MCP_COMPOSE_DIR,
|
cwd=CHAR_MCP_COMPOSE_DIR,
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
print('character-mcp container started.')
|
logger.info('character-mcp container started.')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('WARNING: docker not found on PATH — character-mcp will not be started automatically.')
|
logger.warning('docker not found on PATH — character-mcp will not be started automatically.')
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('WARNING: docker timed out while starting character-mcp.')
|
logger.warning('docker timed out while starting character-mcp.')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'WARNING: Could not ensure character-mcp is running: {e}')
|
logger.warning('Could not ensure character-mcp is running: %s', e)
|
||||||
|
|||||||
449
services/sync.py
449
services/sync.py
@@ -57,7 +57,7 @@ def sync_characters():
|
|||||||
if character.image_path:
|
if character.image_path:
|
||||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], 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):
|
if not os.path.exists(full_img_path):
|
||||||
print(f"Image missing for {character.name}, clearing path.")
|
logger.warning("Image missing for %s, clearing path.", character.name)
|
||||||
character.image_path = None
|
character.image_path = None
|
||||||
|
|
||||||
# Explicitly tell SQLAlchemy the JSON field was modified
|
# Explicitly tell SQLAlchemy the JSON field was modified
|
||||||
@@ -73,7 +73,7 @@ def sync_characters():
|
|||||||
_sync_nsfw_from_tags(new_char, data)
|
_sync_nsfw_from_tags(new_char, data)
|
||||||
db.session.add(new_char)
|
db.session.add(new_char)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing {filename}: {e}")
|
logger.error("Error importing %s: %s", filename, e)
|
||||||
|
|
||||||
# Remove characters that are no longer in the folder
|
# Remove characters that are no longer in the folder
|
||||||
all_characters = Character.query.all()
|
all_characters = Character.query.all()
|
||||||
@@ -83,66 +83,82 @@ def sync_characters():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
def sync_outfits():
|
def _sync_category(config_key, model_class, id_field, name_field,
|
||||||
if not os.path.exists(current_app.config['CLOTHING_DIR']):
|
extra_fn=None, sync_nsfw=True):
|
||||||
|
"""Generic sync: load JSON files from a data directory into the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_key: app.config key for the data directory (e.g. 'CLOTHING_DIR')
|
||||||
|
model_class: SQLAlchemy model class (e.g. Outfit)
|
||||||
|
id_field: JSON key for the entity ID (e.g. 'outfit_id')
|
||||||
|
name_field: JSON key for the display name (e.g. 'outfit_name')
|
||||||
|
extra_fn: optional callable(entity, data) for category-specific field updates
|
||||||
|
sync_nsfw: if True, call _sync_nsfw_from_tags on create/update
|
||||||
|
"""
|
||||||
|
data_dir = current_app.config.get(config_key)
|
||||||
|
if not data_dir or not os.path.exists(data_dir):
|
||||||
return
|
return
|
||||||
|
|
||||||
current_ids = []
|
current_ids = []
|
||||||
|
|
||||||
for filename in os.listdir(current_app.config['CLOTHING_DIR']):
|
for filename in os.listdir(data_dir):
|
||||||
if filename.endswith('.json'):
|
if filename.endswith('.json'):
|
||||||
file_path = os.path.join(current_app.config['CLOTHING_DIR'], filename)
|
file_path = os.path.join(data_dir, filename)
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r') as f:
|
with open(file_path, 'r') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
outfit_id = data.get('outfit_id') or filename.replace('.json', '')
|
entity_id = data.get(id_field) or filename.replace('.json', '')
|
||||||
|
current_ids.append(entity_id)
|
||||||
|
|
||||||
current_ids.append(outfit_id)
|
slug = re.sub(r'[^a-zA-Z0-9_]', '', entity_id)
|
||||||
|
entity = model_class.query.filter_by(**{id_field: entity_id}).first()
|
||||||
|
name = data.get(name_field, entity_id.replace('_', ' ').title())
|
||||||
|
|
||||||
# Generate URL-safe slug: remove special characters from outfit_id
|
if entity:
|
||||||
slug = re.sub(r'[^a-zA-Z0-9_]', '', outfit_id)
|
entity.data = data
|
||||||
|
entity.name = name
|
||||||
|
entity.slug = slug
|
||||||
|
entity.filename = filename
|
||||||
|
if sync_nsfw:
|
||||||
|
_sync_nsfw_from_tags(entity, data)
|
||||||
|
if extra_fn:
|
||||||
|
extra_fn(entity, data)
|
||||||
|
|
||||||
# Check if outfit already exists
|
if entity.image_path:
|
||||||
outfit = Outfit.query.filter_by(outfit_id=outfit_id).first()
|
full_img_path = os.path.join(
|
||||||
name = data.get('outfit_name', outfit_id.replace('_', ' ').title())
|
current_app.config['UPLOAD_FOLDER'], entity.image_path)
|
||||||
|
|
||||||
if outfit:
|
|
||||||
outfit.data = data
|
|
||||||
outfit.name = name
|
|
||||||
outfit.slug = slug
|
|
||||||
outfit.filename = filename
|
|
||||||
_sync_nsfw_from_tags(outfit, data)
|
|
||||||
|
|
||||||
# Check if cover image still exists
|
|
||||||
if outfit.image_path:
|
|
||||||
full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], outfit.image_path)
|
|
||||||
if not os.path.exists(full_img_path):
|
if not os.path.exists(full_img_path):
|
||||||
print(f"Image missing for {outfit.name}, clearing path.")
|
logger.warning("Image missing for %s, clearing path.", entity.name)
|
||||||
outfit.image_path = None
|
entity.image_path = None
|
||||||
|
|
||||||
# Explicitly tell SQLAlchemy the JSON field was modified
|
flag_modified(entity, "data")
|
||||||
flag_modified(outfit, "data")
|
|
||||||
else:
|
else:
|
||||||
new_outfit = Outfit(
|
kwargs = {
|
||||||
outfit_id=outfit_id,
|
id_field: entity_id,
|
||||||
slug=slug,
|
'slug': slug,
|
||||||
filename=filename,
|
'filename': filename,
|
||||||
name=name,
|
'name': name,
|
||||||
data=data
|
'data': data,
|
||||||
)
|
}
|
||||||
_sync_nsfw_from_tags(new_outfit, data)
|
new_entity = model_class(**kwargs)
|
||||||
db.session.add(new_outfit)
|
if sync_nsfw:
|
||||||
|
_sync_nsfw_from_tags(new_entity, data)
|
||||||
|
if extra_fn:
|
||||||
|
extra_fn(new_entity, data)
|
||||||
|
db.session.add(new_entity)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error importing outfit {filename}: {e}")
|
logger.error("Error importing %s: %s", filename, e)
|
||||||
|
|
||||||
# Remove outfits that are no longer in the folder
|
for entity in model_class.query.all():
|
||||||
all_outfits = Outfit.query.all()
|
if getattr(entity, id_field) not in current_ids:
|
||||||
for outfit in all_outfits:
|
db.session.delete(entity)
|
||||||
if outfit.outfit_id not in current_ids:
|
|
||||||
db.session.delete(outfit)
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_outfits():
|
||||||
|
_sync_category('CLOTHING_DIR', Outfit, 'outfit_id', 'outfit_name')
|
||||||
|
|
||||||
def ensure_default_outfit():
|
def ensure_default_outfit():
|
||||||
"""Ensure a default outfit file exists and is registered in the database.
|
"""Ensure a default outfit file exists and is registered in the database.
|
||||||
|
|
||||||
@@ -226,114 +242,16 @@ def ensure_default_outfit():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_look_extra(entity, data):
|
||||||
|
entity.character_id = data.get('character_id', None)
|
||||||
|
|
||||||
def sync_looks():
|
def sync_looks():
|
||||||
if not os.path.exists(current_app.config['LOOKS_DIR']):
|
_sync_category('LOOKS_DIR', Look, 'look_id', 'look_name',
|
||||||
return
|
extra_fn=_sync_look_extra)
|
||||||
|
|
||||||
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
|
|
||||||
_sync_nsfw_from_tags(look, data)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
_sync_nsfw_from_tags(new_look, 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():
|
def sync_presets():
|
||||||
if not os.path.exists(current_app.config['PRESETS_DIR']):
|
_sync_category('PRESETS_DIR', Preset, 'preset_id', 'preset_name',
|
||||||
return
|
sync_nsfw=False)
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -404,240 +322,16 @@ def _resolve_preset_fields(preset_data):
|
|||||||
|
|
||||||
|
|
||||||
def sync_actions():
|
def sync_actions():
|
||||||
if not os.path.exists(current_app.config['ACTIONS_DIR']):
|
_sync_category('ACTIONS_DIR', Action, 'action_id', 'action_name')
|
||||||
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
|
|
||||||
_sync_nsfw_from_tags(action, data)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
_sync_nsfw_from_tags(new_action, 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():
|
def sync_styles():
|
||||||
if not os.path.exists(current_app.config['STYLES_DIR']):
|
_sync_category('STYLES_DIR', Style, 'style_id', 'style_name')
|
||||||
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
|
|
||||||
_sync_nsfw_from_tags(style, data)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
_sync_nsfw_from_tags(new_style, 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():
|
def sync_detailers():
|
||||||
if not os.path.exists(current_app.config['DETAILERS_DIR']):
|
_sync_category('DETAILERS_DIR', Detailer, 'detailer_id', 'detailer_name')
|
||||||
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
|
|
||||||
_sync_nsfw_from_tags(detailer, data)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
_sync_nsfw_from_tags(new_detailer, 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():
|
def sync_scenes():
|
||||||
if not os.path.exists(current_app.config['SCENES_DIR']):
|
_sync_category('SCENES_DIR', Scene, 'scene_id', 'scene_name')
|
||||||
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
|
|
||||||
_sync_nsfw_from_tags(scene, data)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
_sync_nsfw_from_tags(new_scene, 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):
|
def _default_checkpoint_data(checkpoint_path, filename):
|
||||||
"""Return template-default data for a checkpoint with no JSON file."""
|
"""Return template-default data for a checkpoint with no JSON file."""
|
||||||
@@ -650,6 +344,7 @@ def _default_checkpoint_data(checkpoint_path, filename):
|
|||||||
"steps": 25,
|
"steps": 25,
|
||||||
"cfg": 5,
|
"cfg": 5,
|
||||||
"sampler_name": "euler_ancestral",
|
"sampler_name": "euler_ancestral",
|
||||||
|
"scheduler": "normal",
|
||||||
"vae": "integrated"
|
"vae": "integrated"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +364,7 @@ def sync_checkpoints():
|
|||||||
if ckpt_path:
|
if ckpt_path:
|
||||||
json_data_by_path[ckpt_path] = data
|
json_data_by_path[ckpt_path] = data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading checkpoint JSON {filename}: {e}")
|
logger.error("Error reading checkpoint JSON %s: %s", filename, e)
|
||||||
|
|
||||||
current_ids = []
|
current_ids = []
|
||||||
dirs = [
|
dirs = [
|
||||||
|
|||||||
267
static/js/detail-common.js
Normal file
267
static/js/detail-common.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Shared JS for all resource detail pages.
|
||||||
|
*
|
||||||
|
* Usage: call initDetailPage(options) from the template's <script> block.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* batchItems — Array of {slug, name} for batch generation (optional)
|
||||||
|
* jsonEditorUrl — URL for save_json route (optional, enables JSON editor)
|
||||||
|
* hasPreviewGallery — If true, enables addToPreviewGallery helper (default: !!batchItems)
|
||||||
|
*/
|
||||||
|
function initDetailPage(options = {}) {
|
||||||
|
const {
|
||||||
|
batchItems = [],
|
||||||
|
jsonEditorUrl = null,
|
||||||
|
hasPreviewGallery = batchItems.length > 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// DOM references
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const form = document.getElementById('generate-form');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressContainer = document.getElementById('progress-container');
|
||||||
|
const progressLabel = document.getElementById('progress-label');
|
||||||
|
const previewCard = document.getElementById('preview-card');
|
||||||
|
const previewImg = document.getElementById('preview-img');
|
||||||
|
const previewPath = document.getElementById('preview-path');
|
||||||
|
const replaceBtn = document.getElementById('replace-cover-btn');
|
||||||
|
const previewHeader = document.getElementById('preview-card-header');
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Favourite toggle (delegated)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(btn.dataset.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Preview selection
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function selectPreview(relativePath, imageUrl) {
|
||||||
|
if (!relativePath) return;
|
||||||
|
previewImg.src = imageUrl;
|
||||||
|
previewPath.value = relativePath;
|
||||||
|
replaceBtn.disabled = false;
|
||||||
|
previewCard.classList.remove('d-none');
|
||||||
|
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
||||||
|
previewCard.classList.replace('border-secondary', 'border-success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click any image with data-preview-path to select it
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const img = e.target.closest('img[data-preview-path]');
|
||||||
|
if (img) selectPreview(img.dataset.previewPath, img.src);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Job polling
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
async function waitForJob(jobId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.status === 'done') {
|
||||||
|
clearInterval(poll);
|
||||||
|
resolve(data);
|
||||||
|
} else if (data.status === 'failed' || data.status === 'removed') {
|
||||||
|
clearInterval(poll);
|
||||||
|
reject(new Error(data.error || 'Job failed'));
|
||||||
|
} else if (data.status === 'processing') {
|
||||||
|
progressLabel.textContent = 'Generating\u2026';
|
||||||
|
} else {
|
||||||
|
progressLabel.textContent = 'Queued\u2026';
|
||||||
|
}
|
||||||
|
} catch (err) { console.error('Poll error:', err); }
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Preview gallery helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
||||||
|
const gallery = document.getElementById('preview-gallery');
|
||||||
|
if (!gallery) return;
|
||||||
|
const placeholder = document.getElementById('gallery-empty');
|
||||||
|
if (placeholder) placeholder.remove();
|
||||||
|
const col = document.createElement('div');
|
||||||
|
col.className = 'col';
|
||||||
|
col.innerHTML = `<div class="position-relative">
|
||||||
|
<img src="${imageUrl}" class="img-fluid rounded"
|
||||||
|
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
||||||
|
data-preview-path="${relativePath}"
|
||||||
|
title="${charName}">
|
||||||
|
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
gallery.insertBefore(col, gallery.firstChild);
|
||||||
|
const badge = document.querySelector('#previews-tab .badge');
|
||||||
|
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
|
||||||
|
else {
|
||||||
|
const tab = document.getElementById('previews-tab');
|
||||||
|
if (tab) tab.insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Form submit handler (single generation)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
const submitter = e.submitter;
|
||||||
|
if (!submitter || submitter.value !== 'preview') return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('action', 'preview');
|
||||||
|
|
||||||
|
progressContainer.classList.remove('d-none');
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressBar.textContent = '';
|
||||||
|
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||||
|
progressLabel.textContent = 'Queuing\u2026';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.getAttribute('action'), {
|
||||||
|
method: 'POST', body: formData,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) { alert('Error: ' + data.error); return; }
|
||||||
|
|
||||||
|
progressLabel.textContent = 'Queued\u2026';
|
||||||
|
const jobResult = await waitForJob(data.job_id);
|
||||||
|
|
||||||
|
if (jobResult.result?.image_url) {
|
||||||
|
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
||||||
|
if (hasPreviewGallery) {
|
||||||
|
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof updateSeedFromResult === 'function') {
|
||||||
|
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);
|
||||||
|
if (hasPreviewGallery) {
|
||||||
|
addToPreviewGallery(jobResult.result.image_url, jobResult.result.relative_path, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Batch generation (secondary categories only)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
if (batchItems.length > 0) {
|
||||||
|
let stopBatch = false;
|
||||||
|
const generateAllBtn = document.getElementById('generate-all-btn');
|
||||||
|
const stopAllBtn = document.getElementById('stop-all-btn');
|
||||||
|
const batchProgress = document.getElementById('batch-progress');
|
||||||
|
const batchLabel = document.getElementById('batch-label');
|
||||||
|
const batchBar = document.getElementById('batch-bar');
|
||||||
|
|
||||||
|
if (generateAllBtn) {
|
||||||
|
generateAllBtn.addEventListener('click', async () => {
|
||||||
|
if (batchItems.length === 0) { alert('No characters available.'); return; }
|
||||||
|
stopBatch = false;
|
||||||
|
generateAllBtn.disabled = true;
|
||||||
|
stopAllBtn.classList.remove('d-none');
|
||||||
|
batchProgress.classList.remove('d-none');
|
||||||
|
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
||||||
|
|
||||||
|
const genForm = document.getElementById('generate-form');
|
||||||
|
const formAction = genForm.getAttribute('action');
|
||||||
|
|
||||||
|
// Phase 1: submit all jobs
|
||||||
|
batchLabel.textContent = 'Queuing all characters\u2026';
|
||||||
|
const pending = [];
|
||||||
|
for (const char of batchItems) {
|
||||||
|
if (stopBatch) break;
|
||||||
|
const fd = new FormData();
|
||||||
|
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(
|
||||||
|
cb => fd.append('include_field', cb.value));
|
||||||
|
fd.append('character_slug', char.slug);
|
||||||
|
fd.append('action', 'preview');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(formAction, {
|
||||||
|
method: 'POST', body: fd,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.error) pending.push({ char, jobId: data.job_id });
|
||||||
|
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: poll all in parallel
|
||||||
|
batchBar.style.width = '0%';
|
||||||
|
let done = 0;
|
||||||
|
const total = pending.length;
|
||||||
|
batchLabel.textContent = `0 / ${total} complete`;
|
||||||
|
|
||||||
|
await Promise.all(pending.map(({ char, jobId }) =>
|
||||||
|
waitForJob(jobId).then(result => {
|
||||||
|
done++;
|
||||||
|
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
||||||
|
batchLabel.textContent = `${done} / ${total} complete`;
|
||||||
|
if (result.result?.image_url) {
|
||||||
|
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
done++;
|
||||||
|
console.error(`Failed for ${char.name}:`, err);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
batchBar.style.width = '100%';
|
||||||
|
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
||||||
|
generateAllBtn.disabled = false;
|
||||||
|
stopAllBtn.classList.add('d-none');
|
||||||
|
setTimeout(() => {
|
||||||
|
batchProgress.classList.add('d-none');
|
||||||
|
batchBar.style.width = '0%';
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopAllBtn) {
|
||||||
|
stopAllBtn.addEventListener('click', () => {
|
||||||
|
stopBatch = true;
|
||||||
|
stopAllBtn.classList.add('d-none');
|
||||||
|
batchLabel.textContent = 'Stopping\u2026';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// JSON editor
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
if (jsonEditorUrl && typeof initJsonEditor === 'function') {
|
||||||
|
initJsonEditor(jsonEditorUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,11 +307,24 @@
|
|||||||
applyGrid() {
|
applyGrid() {
|
||||||
const container = state.dom.container;
|
const container = state.dom.container;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Reset any custom positioning
|
// Reset container styles set by other layouts
|
||||||
|
container.style.height = '';
|
||||||
|
container.style.position = '';
|
||||||
|
container.style.gap = '';
|
||||||
|
|
||||||
|
// Reset any custom positioning on cards and their children
|
||||||
state.images.forEach(img => {
|
state.images.forEach(img => {
|
||||||
if (img.element) {
|
if (img.element) {
|
||||||
img.element.style.cssText = '';
|
img.element.style.cssText = '';
|
||||||
|
|
||||||
|
// Reset child element inline styles set by mosaic/other layouts
|
||||||
|
const imgEl = img.element.querySelector('img');
|
||||||
|
if (imgEl) imgEl.style.cssText = '';
|
||||||
|
const badge = img.element.querySelector('.cat-badge');
|
||||||
|
if (badge) badge.style.cssText = '';
|
||||||
|
const overlay = img.element.querySelector('.overlay');
|
||||||
|
if (overlay) overlay.style.cssText = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
204
static/js/layout-utils.js
Normal file
204
static/js/layout-utils.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Shared utility functions used across the app layout.
|
||||||
|
* Previously defined inline in layout.html.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm and execute a resource delete (soft or hard).
|
||||||
|
* Called from the delete confirmation modal.
|
||||||
|
*/
|
||||||
|
async function confirmResourceDelete(mode) {
|
||||||
|
const resourceDeleteModal = bootstrap.Modal.getInstance(
|
||||||
|
document.getElementById('resourceDeleteModal'));
|
||||||
|
resourceDeleteModal.hide();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({mode}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
const card = document.getElementById(`card-${_rdmSlug}`);
|
||||||
|
if (card) card.remove();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + (data.error || 'unknown error'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Delete failed: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate tags for a single resource via the LLM queue.
|
||||||
|
* Called from detail page tag regeneration buttons.
|
||||||
|
*/
|
||||||
|
function regenerateTags(category, slug) {
|
||||||
|
const btn = document.getElementById('regenerate-tags-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const origText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating\u2026';
|
||||||
|
fetch(`/api/${category}/${slug}/regenerate_tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
|
||||||
|
.then(({ok, data}) => {
|
||||||
|
if (ok && data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
alert('Regeneration failed: ' + err);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the JSON editor modal for a resource detail page.
|
||||||
|
* Provides simple form view and raw JSON (advanced) editing.
|
||||||
|
*/
|
||||||
|
function initJsonEditor(saveUrl) {
|
||||||
|
const jsonModal = document.getElementById('jsonEditorModal');
|
||||||
|
if (!jsonModal) return;
|
||||||
|
const textarea = document.getElementById('json-editor-textarea');
|
||||||
|
const errBox = document.getElementById('json-editor-error');
|
||||||
|
const simplePanel = document.getElementById('json-simple-panel');
|
||||||
|
const advancedPanel = document.getElementById('json-advanced-panel');
|
||||||
|
const simpleTab = document.getElementById('json-simple-tab');
|
||||||
|
const advancedTab = document.getElementById('json-advanced-tab');
|
||||||
|
let activeTab = 'simple';
|
||||||
|
|
||||||
|
function buildSimpleForm(data) {
|
||||||
|
simplePanel.innerHTML = '';
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row mb-2 align-items-start';
|
||||||
|
|
||||||
|
const labelCol = document.createElement('div');
|
||||||
|
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
|
||||||
|
labelCol.textContent = key.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
const inputCol = document.createElement('div');
|
||||||
|
inputCol.className = 'col-sm-9';
|
||||||
|
|
||||||
|
let el;
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'form-check mt-2';
|
||||||
|
el = document.createElement('input');
|
||||||
|
el.type = 'checkbox';
|
||||||
|
el.className = 'form-check-input';
|
||||||
|
el.checked = value;
|
||||||
|
el.dataset.dtype = 'boolean';
|
||||||
|
wrap.appendChild(el);
|
||||||
|
inputCol.appendChild(wrap);
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
el = document.createElement('input');
|
||||||
|
el.type = 'number';
|
||||||
|
el.step = 'any';
|
||||||
|
el.className = 'form-control form-control-sm';
|
||||||
|
el.value = value;
|
||||||
|
el.dataset.dtype = 'number';
|
||||||
|
inputCol.appendChild(el);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
if (value.length > 80) {
|
||||||
|
el = document.createElement('textarea');
|
||||||
|
el.className = 'form-control form-control-sm';
|
||||||
|
el.rows = 3;
|
||||||
|
} else {
|
||||||
|
el = document.createElement('input');
|
||||||
|
el.type = 'text';
|
||||||
|
el.className = 'form-control form-control-sm';
|
||||||
|
}
|
||||||
|
el.value = value;
|
||||||
|
el.dataset.dtype = 'string';
|
||||||
|
inputCol.appendChild(el);
|
||||||
|
} else {
|
||||||
|
el = document.createElement('textarea');
|
||||||
|
el.className = 'form-control form-control-sm font-monospace';
|
||||||
|
const lines = JSON.stringify(value, null, 2).split('\n');
|
||||||
|
el.rows = Math.min(10, lines.length + 1);
|
||||||
|
el.value = JSON.stringify(value, null, 2);
|
||||||
|
el.dataset.dtype = 'json';
|
||||||
|
inputCol.appendChild(el);
|
||||||
|
}
|
||||||
|
el.dataset.key = key;
|
||||||
|
row.appendChild(labelCol);
|
||||||
|
row.appendChild(inputCol);
|
||||||
|
simplePanel.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSimpleForm() {
|
||||||
|
const result = {};
|
||||||
|
simplePanel.querySelectorAll('[data-key]').forEach(el => {
|
||||||
|
const key = el.dataset.key;
|
||||||
|
const dtype = el.dataset.dtype;
|
||||||
|
if (dtype === 'boolean') result[key] = el.checked;
|
||||||
|
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
|
||||||
|
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
|
||||||
|
else result[key] = el.value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleTab.addEventListener('click', () => {
|
||||||
|
errBox.classList.add('d-none');
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(textarea.value); }
|
||||||
|
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON \u2014 ' + e.message; errBox.classList.remove('d-none'); return; }
|
||||||
|
buildSimpleForm(data);
|
||||||
|
simplePanel.classList.remove('d-none');
|
||||||
|
advancedPanel.classList.add('d-none');
|
||||||
|
simpleTab.classList.add('active');
|
||||||
|
advancedTab.classList.remove('active');
|
||||||
|
activeTab = 'simple';
|
||||||
|
});
|
||||||
|
|
||||||
|
advancedTab.addEventListener('click', () => {
|
||||||
|
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
|
||||||
|
advancedPanel.classList.remove('d-none');
|
||||||
|
simplePanel.classList.add('d-none');
|
||||||
|
advancedTab.classList.add('active');
|
||||||
|
simpleTab.classList.remove('active');
|
||||||
|
activeTab = 'advanced';
|
||||||
|
});
|
||||||
|
|
||||||
|
jsonModal.addEventListener('show.bs.modal', () => {
|
||||||
|
const raw = document.getElementById('json-raw-data').textContent;
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(raw); } catch { data = {}; }
|
||||||
|
buildSimpleForm(data);
|
||||||
|
textarea.value = JSON.stringify(data, null, 2);
|
||||||
|
simplePanel.classList.remove('d-none');
|
||||||
|
advancedPanel.classList.add('d-none');
|
||||||
|
simpleTab.classList.add('active');
|
||||||
|
advancedTab.classList.remove('active');
|
||||||
|
activeTab = 'simple';
|
||||||
|
errBox.classList.add('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('json-save-btn').addEventListener('click', async () => {
|
||||||
|
errBox.classList.add('d-none');
|
||||||
|
let parsed;
|
||||||
|
if (activeTab === 'simple') {
|
||||||
|
parsed = readSimpleForm();
|
||||||
|
} else {
|
||||||
|
try { parsed = JSON.parse(textarea.value); }
|
||||||
|
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
|
||||||
|
}
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('json_data', JSON.stringify(parsed));
|
||||||
|
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
|
||||||
|
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
193
static/js/library-toolbar.js
Normal file
193
static/js/library-toolbar.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Shared library toolbar JS — handles batch generation, tag regeneration,
|
||||||
|
* and bulk create operations for all category index pages.
|
||||||
|
*
|
||||||
|
* Reads configuration from data attributes on the toolbar wrapper element:
|
||||||
|
* data-toolbar-category — e.g. "outfits"
|
||||||
|
* data-get-missing-url — e.g. "/get_missing_outfits"
|
||||||
|
* data-clear-covers-url — e.g. "/clear_all_outfit_covers"
|
||||||
|
* data-generate-url — e.g. "/outfit/{slug}/generate" (with {slug} placeholder)
|
||||||
|
* data-regen-tags-category — e.g. "outfits" (for /admin/bulk_regenerate_tags/<cat>)
|
||||||
|
* data-bulk-create-url — e.g. "/outfits/bulk_create"
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const toolbar = document.querySelector('[data-toolbar-category]');
|
||||||
|
if (!toolbar) return;
|
||||||
|
|
||||||
|
const category = toolbar.dataset.toolbarCategory;
|
||||||
|
const getMissingUrl = toolbar.dataset.getMissingUrl;
|
||||||
|
const clearCoversUrl = toolbar.dataset.clearCoversUrl;
|
||||||
|
const generateUrlPattern = toolbar.dataset.generateUrl;
|
||||||
|
const regenTagsCat = toolbar.dataset.regenTagsCategory;
|
||||||
|
const bulkCreateUrl = toolbar.dataset.bulkCreateUrl;
|
||||||
|
|
||||||
|
const batchBtn = document.getElementById('batch-generate-btn');
|
||||||
|
const regenAllBtn = document.getElementById('regenerate-all-btn');
|
||||||
|
const regenTagsBtn = document.getElementById('regen-tags-all-btn');
|
||||||
|
const bulkCreateBtn = document.getElementById('bulk-create-btn');
|
||||||
|
const bulkOverwriteBtn = document.getElementById('bulk-overwrite-btn');
|
||||||
|
|
||||||
|
// --- Utility: poll a job until done ---
|
||||||
|
function waitForJob(jobId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/queue/${jobId}/status`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
||||||
|
else if (data.status === 'failed' || data.status === 'removed') {
|
||||||
|
clearInterval(poll); reject(new Error(data.error || 'Job failed'));
|
||||||
|
}
|
||||||
|
} catch (err) { /* ignore transient errors */ }
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Batch Generate Missing Covers ---
|
||||||
|
async function runBatch() {
|
||||||
|
if (!getMissingUrl || !generateUrlPattern) return;
|
||||||
|
|
||||||
|
const response = await fetch(getMissingUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
const missing = data.missing;
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
alert('No items missing cover images.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchBtn) batchBtn.disabled = true;
|
||||||
|
if (regenAllBtn) regenAllBtn.disabled = true;
|
||||||
|
|
||||||
|
// Phase 1: Queue all jobs
|
||||||
|
const jobs = [];
|
||||||
|
for (const item of missing) {
|
||||||
|
try {
|
||||||
|
const url = generateUrlPattern.replace('{slug}', item.slug);
|
||||||
|
const body = new URLSearchParams({ action: 'replace' });
|
||||||
|
// Secondary categories need a random character for generation
|
||||||
|
if (category !== 'characters') {
|
||||||
|
body.set('character_slug', '__random__');
|
||||||
|
}
|
||||||
|
const genResp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const genData = await genResp.json();
|
||||||
|
if (genData.job_id) jobs.push({ item, jobId: genData.job_id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to queue ${item.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Poll all concurrently, update card images as they finish
|
||||||
|
await Promise.all(jobs.map(async ({ item, jobId }) => {
|
||||||
|
try {
|
||||||
|
const jobResult = await waitForJob(jobId);
|
||||||
|
if (jobResult.result && jobResult.result.image_url) {
|
||||||
|
const img = document.getElementById(`img-${item.slug}`);
|
||||||
|
const noImgSpan = document.getElementById(`no-img-${item.slug}`);
|
||||||
|
if (img) { img.src = jobResult.result.image_url; img.classList.remove('d-none'); }
|
||||||
|
if (noImgSpan) noImgSpan.classList.add('d-none');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed for ${item.name}:`, err);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (batchBtn) batchBtn.disabled = false;
|
||||||
|
if (regenAllBtn) regenAllBtn.disabled = false;
|
||||||
|
alert(`Batch generation complete! ${jobs.length} images queued.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchBtn) {
|
||||||
|
batchBtn.addEventListener('click', async () => {
|
||||||
|
if (!getMissingUrl) return;
|
||||||
|
const response = await fetch(getMissingUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.missing.length === 0) {
|
||||||
|
alert('No items missing cover images.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Generate cover images for ${data.missing.length} items?`)) return;
|
||||||
|
runBatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regenerate All Covers ---
|
||||||
|
if (regenAllBtn) {
|
||||||
|
regenAllBtn.addEventListener('click', async () => {
|
||||||
|
if (!clearCoversUrl) return;
|
||||||
|
if (!confirm('This will unassign ALL current cover images and generate new ones. Proceed?')) return;
|
||||||
|
|
||||||
|
const clearResp = await fetch(clearCoversUrl, { method: 'POST' });
|
||||||
|
if (clearResp.ok) {
|
||||||
|
document.querySelectorAll('.img-container img').forEach(img => img.classList.add('d-none'));
|
||||||
|
document.querySelectorAll('.img-container .text-muted').forEach(span => span.classList.remove('d-none'));
|
||||||
|
runBatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Regenerate Tags (LLM) ---
|
||||||
|
if (regenTagsBtn && regenTagsCat) {
|
||||||
|
regenTagsBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Regenerate tags for ALL items using the LLM? This will consume API credits.')) return;
|
||||||
|
regenTagsBtn.disabled = true;
|
||||||
|
const origText = regenTagsBtn.textContent;
|
||||||
|
regenTagsBtn.textContent = 'Queuing...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/bulk_regenerate_tags/${regenTagsCat}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert(`Queued ${data.queued} tag regeneration tasks. Watch progress in the queue.`);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Request failed: ' + err.message);
|
||||||
|
}
|
||||||
|
regenTagsBtn.disabled = false;
|
||||||
|
regenTagsBtn.textContent = origText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulk Create from LoRAs (LLM) ---
|
||||||
|
async function doBulkCreate(overwrite) {
|
||||||
|
if (!bulkCreateUrl) return;
|
||||||
|
const body = overwrite ? new URLSearchParams({ overwrite: 'true' }) : undefined;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(bulkCreateUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: body,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert(`Queued ${data.queued} LLM tasks (${data.skipped} skipped). Watch progress in the queue.`);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Request failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bulkCreateBtn) {
|
||||||
|
bulkCreateBtn.addEventListener('click', () => {
|
||||||
|
if (!confirm('Create entries from LoRA files using the LLM? This will consume API credits.')) return;
|
||||||
|
doBulkCreate(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bulkOverwriteBtn) {
|
||||||
|
bulkOverwriteBtn.addEventListener('click', () => {
|
||||||
|
if (!confirm('WARNING: This will overwrite ALL existing metadata using the LLM. This consumes API credits. Proceed?')) return;
|
||||||
|
doBulkCreate(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -294,192 +294,20 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_action_json", slug=action.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
const charSelect = document.getElementById('character_select');
|
|
||||||
const charContext = document.getElementById('character-context');
|
|
||||||
|
|
||||||
charSelect.addEventListener('change', () => {
|
|
||||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(data.job_id);
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
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 %}
|
|
||||||
];
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative preview-img-wrapper">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded preview-img"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
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', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
const genForm = document.getElementById('generate-form');
|
|
||||||
const formAction = genForm.getAttribute('action');
|
|
||||||
batchLabel.textContent = 'Queuing all characters…';
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBar.style.width = '0%';
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
batchLabel.textContent = `0 / ${total} complete`;
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping…';
|
|
||||||
});
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_action_json", slug=action.slug) }}');
|
|
||||||
|
|
||||||
// Register preview gallery for navigation
|
|
||||||
registerGallery('#preview-gallery', '.preview-img');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Character-context toggle (action-specific)
|
||||||
|
const charSelect = document.getElementById('character_select');
|
||||||
|
const charContext = document.getElementById('character-context');
|
||||||
|
charSelect.addEventListener('change', () => {
|
||||||
|
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ action.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}">🗑</button>
|
data-category="actions" data-slug="{{ action.slug }}" data-name="{{ action.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,6 +101,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = ckpt.data.tags if ckpt.data is mapping and ckpt.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tags.art_style %}<span class="badge bg-info">{{ tags.art_style }}</span>{% endif %}
|
||||||
|
{% if tags.base_model %}<span class="badge bg-primary">{{ tags.base_model }}</span>{% endif %}
|
||||||
|
{% if ckpt.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
|
{% if ckpt.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -235,184 +248,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewCardHeader = document.getElementById('preview-card-header');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
|
||||||
else progressLabel.textContent = 'Queued…';
|
|
||||||
} catch (err) {}
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
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'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch: Generate All Characters
|
|
||||||
const allCharacters = [
|
|
||||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
|
||||||
{% endfor %}
|
|
||||||
];
|
|
||||||
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative preview-img-wrapper">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded preview-img"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>
|
|
||||||
</div>`;
|
|
||||||
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', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
// Phase 1: submit all jobs immediately
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: poll all in parallel
|
|
||||||
batchLabel.textContent = `0 / ${pending.length} complete`;
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping after current submissions...';
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 'Endless');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// JSON Editor
|
|
||||||
initJsonEditor('{{ url_for("save_checkpoint_json", slug=ckpt.slug) }}');
|
|
||||||
|
|
||||||
// Register preview gallery for navigation
|
|
||||||
registerGallery('#preview-gallery', '.preview-img');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
<div class="card-footer d-flex justify-content-between align-items-center p-1">
|
||||||
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
<small class="text-muted" title="{{ ckpt.checkpoint_path }}">{{ ckpt.checkpoint_path.split('/')[0] }}</small>
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}">🗑</button>
|
data-category="checkpoints" data-slug="{{ ckpt.slug }}" data-name="{{ ckpt.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -271,114 +271,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({});
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
});
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
|
|
||||||
let currentJobId = null;
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clicking any image with data-preview-path selects it into the preview pane
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') {
|
|
||||||
clearInterval(poll);
|
|
||||||
resolve(data);
|
|
||||||
} else if (data.status === 'failed' || data.status === 'removed') {
|
|
||||||
clearInterval(poll);
|
|
||||||
reject(new Error(data.error || 'Job failed'));
|
|
||||||
} else if (data.status === 'processing') {
|
|
||||||
progressLabel.textContent = 'Generating…';
|
|
||||||
} else {
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%';
|
|
||||||
progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData,
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
|
|
||||||
currentJobId = data.job_id;
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(currentJobId);
|
|
||||||
currentJobId = null;
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Endless mode callback
|
|
||||||
window._onEndlessResult = function(jobResult) {
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -122,6 +122,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tags.associated_resource %}<span class="badge bg-info">{{ tags.associated_resource }}</span>{% endif %}
|
||||||
|
{% if tags.adetailer_targets %}<span class="badge bg-primary">{{ tags.adetailer_targets | join(', ') }}</span>{% endif %}
|
||||||
|
{% if detailer.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
|
{% if detailer.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -265,188 +278,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_detailer_json", slug=detailer.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
const charSelect = document.getElementById('character_select');
|
|
||||||
const charContext = document.getElementById('character-context');
|
|
||||||
const actionSelect = document.getElementById('action_select');
|
|
||||||
|
|
||||||
charSelect.addEventListener('change', () => {
|
|
||||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(data.job_id);
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
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 %}
|
|
||||||
];
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
|
|
||||||
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
gallery.insertBefore(col, gallery.firstChild);
|
|
||||||
const badge = document.querySelector('#previews-tab .badge');
|
|
||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
|
|
||||||
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
const genForm = document.getElementById('generate-form');
|
|
||||||
const formAction = genForm.getAttribute('action');
|
|
||||||
batchLabel.textContent = 'Queuing all characters…';
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action_slug', actionSelect.value);
|
|
||||||
fd.append('extra_positive', document.getElementById('extra_positive').value);
|
|
||||||
fd.append('extra_negative', document.getElementById('extra_negative').value);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBar.style.width = '0%';
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
batchLabel.textContent = `0 / ${total} complete`;
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping…';
|
|
||||||
});
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_detailer_json", slug=detailer.slug) }}');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@
|
|||||||
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ detailer.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ detailer.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}">🗑</button>
|
data-category="detailers" data-slug="{{ detailer.slug }}" data-name="{{ detailer.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -332,10 +332,10 @@
|
|||||||
class="btn btn-sm btn-outline-light py-0 px-2"
|
class="btn btn-sm btn-outline-light py-0 px-2"
|
||||||
onclick="event.stopPropagation()">Generator</a>
|
onclick="event.stopPropagation()">Generator</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-2"
|
<button class="btn btn-sm btn-danger py-0 px-2"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
|
onclick='event.stopPropagation(); openDeleteModal({{ img.path | tojson }}, {{ img.item_name | tojson }})'>
|
||||||
🗑
|
<img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,8 +84,8 @@
|
|||||||
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ char.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}">🗑</button>
|
data-category="characters" data-slug="{{ char.slug }}" data-name="{{ char.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,192 +217,8 @@
|
|||||||
document.getElementById('rdm-name').textContent = name;
|
document.getElementById('rdm-name').textContent = name;
|
||||||
resourceDeleteModal.show();
|
resourceDeleteModal.show();
|
||||||
}
|
}
|
||||||
async function confirmResourceDelete(mode) {
|
|
||||||
resourceDeleteModal.hide();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({mode}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.status === 'ok') {
|
|
||||||
const card = document.getElementById(`card-${_rdmSlug}`);
|
|
||||||
if (card) card.remove();
|
|
||||||
} else {
|
|
||||||
alert('Delete failed: ' + (data.error || 'unknown error'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Delete failed: ' + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function regenerateTags(category, slug) {
|
|
||||||
const btn = document.getElementById('regenerate-tags-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
const origText = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Regenerating…';
|
|
||||||
fetch(`/api/${category}/${slug}/regenerate_tags`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
|
||||||
})
|
|
||||||
.then(r => r.json().then(d => ({ok: r.ok, data: d})))
|
|
||||||
.then(({ok, data}) => {
|
|
||||||
if (ok && data.success) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Regeneration failed: ' + (data.error || 'Unknown error'));
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origText;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
alert('Regeneration failed: ' + err);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origText;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initJsonEditor(saveUrl) {
|
|
||||||
const jsonModal = document.getElementById('jsonEditorModal');
|
|
||||||
if (!jsonModal) return;
|
|
||||||
const textarea = document.getElementById('json-editor-textarea');
|
|
||||||
const errBox = document.getElementById('json-editor-error');
|
|
||||||
const simplePanel = document.getElementById('json-simple-panel');
|
|
||||||
const advancedPanel = document.getElementById('json-advanced-panel');
|
|
||||||
const simpleTab = document.getElementById('json-simple-tab');
|
|
||||||
const advancedTab = document.getElementById('json-advanced-tab');
|
|
||||||
let activeTab = 'simple';
|
|
||||||
|
|
||||||
function buildSimpleForm(data) {
|
|
||||||
simplePanel.innerHTML = '';
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'row mb-2 align-items-start';
|
|
||||||
|
|
||||||
const labelCol = document.createElement('div');
|
|
||||||
labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1';
|
|
||||||
labelCol.textContent = key.replace(/_/g, ' ');
|
|
||||||
|
|
||||||
const inputCol = document.createElement('div');
|
|
||||||
inputCol.className = 'col-sm-9';
|
|
||||||
|
|
||||||
let el;
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'form-check mt-2';
|
|
||||||
el = document.createElement('input');
|
|
||||||
el.type = 'checkbox';
|
|
||||||
el.className = 'form-check-input';
|
|
||||||
el.checked = value;
|
|
||||||
el.dataset.dtype = 'boolean';
|
|
||||||
wrap.appendChild(el);
|
|
||||||
inputCol.appendChild(wrap);
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
el = document.createElement('input');
|
|
||||||
el.type = 'number';
|
|
||||||
el.step = 'any';
|
|
||||||
el.className = 'form-control form-control-sm';
|
|
||||||
el.value = value;
|
|
||||||
el.dataset.dtype = 'number';
|
|
||||||
inputCol.appendChild(el);
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
if (value.length > 80) {
|
|
||||||
el = document.createElement('textarea');
|
|
||||||
el.className = 'form-control form-control-sm';
|
|
||||||
el.rows = 3;
|
|
||||||
} else {
|
|
||||||
el = document.createElement('input');
|
|
||||||
el.type = 'text';
|
|
||||||
el.className = 'form-control form-control-sm';
|
|
||||||
}
|
|
||||||
el.value = value;
|
|
||||||
el.dataset.dtype = 'string';
|
|
||||||
inputCol.appendChild(el);
|
|
||||||
} else {
|
|
||||||
el = document.createElement('textarea');
|
|
||||||
el.className = 'form-control form-control-sm font-monospace';
|
|
||||||
const lines = JSON.stringify(value, null, 2).split('\n');
|
|
||||||
el.rows = Math.min(10, lines.length + 1);
|
|
||||||
el.value = JSON.stringify(value, null, 2);
|
|
||||||
el.dataset.dtype = 'json';
|
|
||||||
inputCol.appendChild(el);
|
|
||||||
}
|
|
||||||
el.dataset.key = key;
|
|
||||||
row.appendChild(labelCol);
|
|
||||||
row.appendChild(inputCol);
|
|
||||||
simplePanel.appendChild(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSimpleForm() {
|
|
||||||
const result = {};
|
|
||||||
simplePanel.querySelectorAll('[data-key]').forEach(el => {
|
|
||||||
const key = el.dataset.key;
|
|
||||||
const dtype = el.dataset.dtype;
|
|
||||||
if (dtype === 'boolean') result[key] = el.checked;
|
|
||||||
else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; }
|
|
||||||
else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } }
|
|
||||||
else result[key] = el.value;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
simpleTab.addEventListener('click', () => {
|
|
||||||
errBox.classList.add('d-none');
|
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(textarea.value); }
|
|
||||||
catch (e) { errBox.textContent = 'Cannot switch: invalid JSON — ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
||||||
buildSimpleForm(data);
|
|
||||||
simplePanel.classList.remove('d-none');
|
|
||||||
advancedPanel.classList.add('d-none');
|
|
||||||
simpleTab.classList.add('active');
|
|
||||||
advancedTab.classList.remove('active');
|
|
||||||
activeTab = 'simple';
|
|
||||||
});
|
|
||||||
|
|
||||||
advancedTab.addEventListener('click', () => {
|
|
||||||
textarea.value = JSON.stringify(readSimpleForm(), null, 2);
|
|
||||||
advancedPanel.classList.remove('d-none');
|
|
||||||
simplePanel.classList.add('d-none');
|
|
||||||
advancedTab.classList.add('active');
|
|
||||||
simpleTab.classList.remove('active');
|
|
||||||
activeTab = 'advanced';
|
|
||||||
});
|
|
||||||
|
|
||||||
jsonModal.addEventListener('show.bs.modal', () => {
|
|
||||||
const raw = document.getElementById('json-raw-data').textContent;
|
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(raw); } catch { data = {}; }
|
|
||||||
buildSimpleForm(data);
|
|
||||||
textarea.value = JSON.stringify(data, null, 2);
|
|
||||||
simplePanel.classList.remove('d-none');
|
|
||||||
advancedPanel.classList.add('d-none');
|
|
||||||
simpleTab.classList.add('active');
|
|
||||||
advancedTab.classList.remove('active');
|
|
||||||
activeTab = 'simple';
|
|
||||||
errBox.classList.add('d-none');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('json-save-btn').addEventListener('click', async () => {
|
|
||||||
errBox.classList.add('d-none');
|
|
||||||
let parsed;
|
|
||||||
if (activeTab === 'simple') {
|
|
||||||
parsed = readSimpleForm();
|
|
||||||
} else {
|
|
||||||
try { parsed = JSON.parse(textarea.value); }
|
|
||||||
catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; }
|
|
||||||
}
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('json_data', JSON.stringify(parsed));
|
|
||||||
const resp = await fetch(saveUrl, { method: 'POST', body: fd });
|
|
||||||
const result = await resp.json();
|
|
||||||
if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); }
|
|
||||||
else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/layout-utils.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ---- Service status indicators ----
|
// ---- Service status indicators ----
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -277,90 +277,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
jsonEditorUrl: '{{ url_for("save_look_json", slug=look.slug) }}'
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewCardHeader = document.getElementById('preview-card-header');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewCardHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else if (data.status === 'processing') progressLabel.textContent = 'Generating…';
|
|
||||||
else progressLabel.textContent = 'Queued…';
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
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) {
|
|
||||||
if (src) document.getElementById('modalImage').src = src;
|
|
||||||
}
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_look_json", slug=look.slug) }}');
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@
|
|||||||
{% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ look.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ look.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}">🗑</button>
|
data-category="looks" data-slug="{{ look.slug }}" data-name="{{ look.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -275,209 +275,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_outfit_json", slug=outfit.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clicking any image with data-preview-path selects it into the preview pane
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') {
|
|
||||||
clearInterval(poll);
|
|
||||||
resolve(data);
|
|
||||||
} else if (data.status === 'failed' || data.status === 'removed') {
|
|
||||||
clearInterval(poll);
|
|
||||||
reject(new Error(data.error || 'Job failed'));
|
|
||||||
} else if (data.status === 'processing') {
|
|
||||||
progressLabel.textContent = 'Generating…';
|
|
||||||
} else {
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
}
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%';
|
|
||||||
progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData,
|
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(data.job_id);
|
|
||||||
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
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, '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Batch: Generate All Characters
|
|
||||||
const allCharacters = [
|
|
||||||
{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },
|
|
||||||
{% endfor %}
|
|
||||||
];
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
|
|
||||||
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
gallery.insertBefore(col, gallery.firstChild);
|
|
||||||
const badge = document.querySelector('#previews-tab .badge');
|
|
||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
|
|
||||||
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
const genForm = document.getElementById('generate-form');
|
|
||||||
const formAction = genForm.getAttribute('action');
|
|
||||||
|
|
||||||
// Phase 1: submit all jobs immediately
|
|
||||||
batchLabel.textContent = 'Queuing all characters…';
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: poll all in parallel
|
|
||||||
batchBar.style.width = '0%';
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
batchLabel.textContent = `0 / ${total} complete`;
|
|
||||||
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) {
|
|
||||||
addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
done++;
|
|
||||||
console.error(`Failed for ${char.name}:`, err);
|
|
||||||
})
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping…';
|
|
||||||
});
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_outfit_json", slug=outfit.slug) }}');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ outfit.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ outfit.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}">🗑</button>
|
data-category="outfits" data-slug="{{ outfit.slug }}" data-name="{{ outfit.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,20 +113,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Selected Preview -->
|
<!-- Selected Preview -->
|
||||||
<div class="card mb-3" id="preview-pane" {% if not preview_path %}style="display:none"{% endif %}>
|
<div class="card mb-4 {% if preview_path %}border-success{% else %}border-secondary d-none{% endif %}" id="preview-card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center py-1">
|
<div class="card-header {% if preview_path %}bg-success{% else %}bg-secondary{% endif %} text-white d-flex justify-content-between align-items-center" id="preview-card-header">
|
||||||
<small class="fw-semibold">Selected Preview</small>
|
<span>Selected Preview</span>
|
||||||
</div>
|
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post" class="m-0" id="replace-cover-form">
|
||||||
<div class="card-body p-1">
|
|
||||||
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}"
|
|
||||||
class="img-fluid rounded" alt="Preview">
|
|
||||||
</div>
|
|
||||||
<div class="card-footer p-2">
|
|
||||||
<form action="{{ url_for('replace_preset_cover_from_preview', slug=preset.slug) }}" method="post">
|
|
||||||
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
|
<input type="hidden" name="preview_path" id="preview-path" value="{{ preview_path or '' }}">
|
||||||
<button type="submit" class="btn btn-sm btn-warning w-100">Set as Cover</button>
|
<button type="submit" class="btn btn-sm btn-outline-light" id="replace-cover-btn" {% if not preview_path %}disabled{% endif %}>Replace Cover</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="img-container" style="height: auto; min-height: 400px; cursor: pointer;">
|
||||||
|
<img id="preview-img" src="{{ url_for('static', filename='uploads/' + preview_path) if preview_path else '' }}" alt="Preview" class="img-fluid">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,119 +303,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Job polling
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
let currentJobId = null;
|
initDetailPage({
|
||||||
|
jsonEditorUrl: "{{ url_for('save_preset_json', slug=preset.slug) }}"
|
||||||
document.getElementById('generate-form').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = e.submitter;
|
|
||||||
const actionVal = btn.value;
|
|
||||||
const formData = new FormData(this);
|
|
||||||
formData.set('action', actionVal);
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Generating...';
|
|
||||||
|
|
||||||
fetch(this.getAttribute('action'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.job_id) {
|
|
||||||
currentJobId = data.job_id;
|
|
||||||
pollJob(currentJobId, btn, actionVal);
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = btn.dataset.label || 'Generate Preview';
|
|
||||||
alert('Error: ' + (data.error || 'Unknown error'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { btn.disabled = false; });
|
|
||||||
});
|
|
||||||
|
|
||||||
function pollJob(jobId, btn, actionVal) {
|
|
||||||
fetch('/api/queue/' + jobId + '/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'done' && data.result) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = btn.getAttribute('data-orig') || btn.textContent.replace('Generating...', 'Generate Preview');
|
|
||||||
// Add to gallery
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = data.result.image_url;
|
|
||||||
img.className = 'img-fluid rounded';
|
|
||||||
img.style.cursor = 'pointer';
|
|
||||||
img.dataset.previewPath = data.result.relative_path;
|
|
||||||
img.addEventListener('click', () => selectPreview(data.result.relative_path, img.src));
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col-4 col-md-3';
|
|
||||||
col.appendChild(img);
|
|
||||||
document.getElementById('generated-images').prepend(col);
|
|
||||||
document.getElementById('no-images-msg')?.classList.add('d-none');
|
|
||||||
selectPreview(data.result.relative_path, data.result.image_url);
|
|
||||||
updateSeedFromResult(data.result);
|
|
||||||
} else if (data.status === 'failed') {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Generate Preview';
|
|
||||||
alert('Generation failed: ' + (data.error || 'Unknown error'));
|
|
||||||
} else {
|
|
||||||
setTimeout(() => pollJob(jobId, btn, actionVal), 1500);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setTimeout(() => pollJob(jobId, btn, actionVal), 3000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
document.getElementById('preview-path').value = relativePath;
|
|
||||||
document.getElementById('preview-img').src = imageUrl;
|
|
||||||
document.getElementById('preview-pane').style.display = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showImage(src) {
|
|
||||||
if (src) document.getElementById('modalImage').src = src;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate click on generated images
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
window._onEndlessResult = function(jobResult) {
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
selectPreview(jobResult.result.relative_path, jobResult.result.image_url);
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = jobResult.result.image_url;
|
|
||||||
img.className = 'img-fluid rounded';
|
|
||||||
img.style.cursor = 'pointer';
|
|
||||||
img.dataset.previewPath = jobResult.result.relative_path;
|
|
||||||
img.addEventListener('click', () => selectPreview(jobResult.result.relative_path, img.src));
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col-4 col-md-3';
|
|
||||||
col.appendChild(img);
|
|
||||||
document.getElementById('generated-images').prepend(col);
|
|
||||||
document.getElementById('no-images-msg')?.classList.add('d-none');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolution preset buttons
|
|
||||||
document.querySelectorAll('.res-preset').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.getElementById('res-width').value = btn.dataset.w;
|
|
||||||
document.getElementById('res-height').value = btn.dataset.h;
|
|
||||||
document.querySelectorAll('.res-preset').forEach(b => {
|
|
||||||
b.classList.remove('btn-secondary');
|
|
||||||
b.classList.add('btn-outline-secondary');
|
|
||||||
});
|
|
||||||
btn.classList.remove('btn-outline-secondary');
|
|
||||||
btn.classList.add('btn-secondary');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// JSON editor
|
|
||||||
initJsonEditor("{{ url_for('save_preset_json', slug=preset.slug) }}");
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -111,6 +111,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = scene.data.tags if scene.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tags.scene_type %}<span class="badge bg-info">{{ tags.scene_type }}</span>{% endif %}
|
||||||
|
{% if scene.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
|
{% if scene.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -271,192 +283,13 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_scene_json", slug=scene.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
const charSelect = document.getElementById('character_select');
|
|
||||||
const charContext = document.getElementById('character-context');
|
|
||||||
|
|
||||||
charSelect.addEventListener('change', () => {
|
|
||||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(data.job_id);
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
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 %}
|
|
||||||
];
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative preview-img-wrapper">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded preview-img"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
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', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
const genForm = document.getElementById('generate-form');
|
|
||||||
const formAction = genForm.getAttribute('action');
|
|
||||||
batchLabel.textContent = 'Queuing all characters…';
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBar.style.width = '0%';
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
batchLabel.textContent = `0 / ${total} complete`;
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping…';
|
|
||||||
});
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_scene_json", slug=scene.slug) }}');
|
|
||||||
|
|
||||||
// Register preview gallery for navigation
|
|
||||||
registerGallery('#preview-gallery', '.preview-img');
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ scene.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ scene.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}">🗑</button>
|
data-category="scenes" data-slug="{{ scene.slug }}" data-name="{{ scene.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set tags = style.data.tags if style.data.tags is mapping else {} %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark text-white"><span>Tags</span></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tags.style_type %}<span class="badge bg-info">{{ tags.style_type }}</span>{% endif %}
|
||||||
|
{% if style.is_nsfw %}<span class="badge bg-danger">NSFW</span>{% endif %}
|
||||||
|
{% if style.is_favourite %}<span class="badge bg-warning text-dark">★ Favourite</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -263,185 +275,20 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="/static/js/detail-common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Favourite toggle
|
initDetailPage({
|
||||||
document.querySelectorAll('.fav-toggle-btn').forEach(btn => {
|
batchItems: [{% for char in characters %}{ slug: "{{ char.slug }}", name: {{ char.name | tojson }} },{% endfor %}],
|
||||||
btn.addEventListener('click', async (e) => {
|
jsonEditorUrl: '{{ url_for("save_style_json", slug=style.slug) }}'
|
||||||
e.preventDefault();
|
|
||||||
const resp = await fetch(btn.dataset.url, {method:'POST', headers:{'X-Requested-With':'XMLHttpRequest'}});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) btn.querySelector('span').innerHTML = data.is_favourite ? '★' : '☆';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.getElementById('generate-form');
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
const progressContainer = document.getElementById('progress-container');
|
|
||||||
const progressLabel = document.getElementById('progress-label');
|
|
||||||
const previewCard = document.getElementById('preview-card');
|
|
||||||
const previewImg = document.getElementById('preview-img');
|
|
||||||
const previewPath = document.getElementById('preview-path');
|
|
||||||
const replaceBtn = document.getElementById('replace-cover-btn');
|
|
||||||
const previewHeader = document.getElementById('preview-card-header');
|
|
||||||
const charSelect = document.getElementById('character_select');
|
|
||||||
const charContext = document.getElementById('character-context');
|
|
||||||
|
|
||||||
charSelect.addEventListener('change', () => {
|
|
||||||
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectPreview(relativePath, imageUrl) {
|
|
||||||
if (!relativePath) return;
|
|
||||||
previewImg.src = imageUrl;
|
|
||||||
previewPath.value = relativePath;
|
|
||||||
replaceBtn.disabled = false;
|
|
||||||
previewCard.classList.remove('d-none');
|
|
||||||
previewHeader.classList.replace('bg-secondary', 'bg-success');
|
|
||||||
previewCard.classList.replace('border-secondary', 'border-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
const img = e.target.closest('img[data-preview-path]');
|
|
||||||
if (img) selectPreview(img.dataset.previewPath, img.src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function waitForJob(jobId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const poll = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/queue/${jobId}/status`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'done') { clearInterval(poll); resolve(data); }
|
|
||||||
else if (data.status === 'failed' || data.status === 'removed') { clearInterval(poll); reject(new Error(data.error || 'Job failed')); }
|
|
||||||
else progressLabel.textContent = data.status === 'processing' ? 'Generating…' : 'Queued…';
|
|
||||||
} catch (err) { console.error('Poll error:', err); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
const submitter = e.submitter;
|
|
||||||
if (!submitter || submitter.value !== 'preview') return;
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(form);
|
|
||||||
formData.append('action', 'preview');
|
|
||||||
progressContainer.classList.remove('d-none');
|
|
||||||
progressBar.style.width = '100%'; progressBar.textContent = '';
|
|
||||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
||||||
progressLabel.textContent = 'Queuing…';
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.getAttribute('action'), {
|
|
||||||
method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) { alert('Error: ' + data.error); return; }
|
|
||||||
progressLabel.textContent = 'Queued…';
|
|
||||||
const jobResult = await waitForJob(data.job_id);
|
|
||||||
if (jobResult.result?.image_url) {
|
|
||||||
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 %}
|
|
||||||
];
|
|
||||||
let stopBatch = false;
|
|
||||||
const generateAllBtn = document.getElementById('generate-all-btn');
|
|
||||||
const stopAllBtn = document.getElementById('stop-all-btn');
|
|
||||||
const batchProgress = document.getElementById('batch-progress');
|
|
||||||
const batchLabel = document.getElementById('batch-label');
|
|
||||||
const batchBar = document.getElementById('batch-bar');
|
|
||||||
|
|
||||||
function addToPreviewGallery(imageUrl, relativePath, charName) {
|
|
||||||
const gallery = document.getElementById('preview-gallery');
|
|
||||||
const placeholder = document.getElementById('gallery-empty');
|
|
||||||
if (placeholder) placeholder.remove();
|
|
||||||
const col = document.createElement('div');
|
|
||||||
col.className = 'col';
|
|
||||||
col.innerHTML = `<div class="position-relative">
|
|
||||||
<img src="${imageUrl}" class="img-fluid rounded"
|
|
||||||
style="cursor: pointer; aspect-ratio: 1; object-fit: cover; width: 100%;"
|
|
||||||
onclick="openGallery([this.querySelector('img') ? this.querySelector('img').src : this.src || ''], 0)"
|
|
||||||
|
|
||||||
data-preview-path="${relativePath}"
|
|
||||||
title="${charName}">
|
|
||||||
${charName ? `<div class="position-absolute bottom-0 start-0 w-100 bg-dark bg-opacity-50 text-white p-1 rounded-bottom" style="font-size: 0.7rem; line-height: 1.2;">${charName}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
gallery.insertBefore(col, gallery.firstChild);
|
|
||||||
const badge = document.querySelector('#previews-tab .badge');
|
|
||||||
if (badge) badge.textContent = parseInt(badge.textContent || '0') + 1;
|
|
||||||
else document.getElementById('previews-tab').insertAdjacentHTML('beforeend', ' <span class="badge bg-secondary">1</span>');
|
|
||||||
}
|
|
||||||
|
|
||||||
generateAllBtn.addEventListener('click', async () => {
|
|
||||||
if (allCharacters.length === 0) { alert('No characters available.'); return; }
|
|
||||||
stopBatch = false;
|
|
||||||
generateAllBtn.disabled = true;
|
|
||||||
stopAllBtn.classList.remove('d-none');
|
|
||||||
batchProgress.classList.remove('d-none');
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById('previews-tab')).show();
|
|
||||||
|
|
||||||
const genForm = document.getElementById('generate-form');
|
|
||||||
const formAction = genForm.getAttribute('action');
|
|
||||||
batchLabel.textContent = 'Queuing all characters…';
|
|
||||||
const pending = [];
|
|
||||||
for (const char of allCharacters) {
|
|
||||||
if (stopBatch) break;
|
|
||||||
const fd = new FormData();
|
|
||||||
genForm.querySelectorAll('input[name="include_field"]:checked').forEach(cb => fd.append('include_field', cb.value));
|
|
||||||
fd.append('character_slug', char.slug);
|
|
||||||
fd.append('action', 'preview');
|
|
||||||
try {
|
|
||||||
const resp = await fetch(formAction, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!data.error) pending.push({ char, jobId: data.job_id });
|
|
||||||
} catch (err) { console.error(`Submit error for ${char.name}:`, err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
batchBar.style.width = '0%';
|
|
||||||
let done = 0;
|
|
||||||
const total = pending.length;
|
|
||||||
batchLabel.textContent = `0 / ${total} complete`;
|
|
||||||
await Promise.all(pending.map(({ char, jobId }) =>
|
|
||||||
waitForJob(jobId).then(result => {
|
|
||||||
done++;
|
|
||||||
batchBar.style.width = `${Math.round((done / total) * 100)}%`;
|
|
||||||
batchLabel.textContent = `${done} / ${total} complete`;
|
|
||||||
if (result.result?.image_url) addToPreviewGallery(result.result.image_url, result.result.relative_path, char.name);
|
|
||||||
}).catch(err => { done++; console.error(`Failed for ${char.name}:`, err); })
|
|
||||||
));
|
|
||||||
|
|
||||||
batchBar.style.width = '100%';
|
|
||||||
batchLabel.textContent = stopBatch ? 'Stopped.' : 'Complete!';
|
|
||||||
generateAllBtn.disabled = false;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
setTimeout(() => { batchProgress.classList.add('d-none'); batchBar.style.width = '0%'; }, 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
stopAllBtn.addEventListener('click', () => {
|
|
||||||
stopBatch = true;
|
|
||||||
stopAllBtn.classList.add('d-none');
|
|
||||||
batchLabel.textContent = 'Stopping…';
|
|
||||||
});
|
|
||||||
|
|
||||||
initJsonEditor('{{ url_for("save_style_json", slug=style.slug) }}');
|
|
||||||
|
|
||||||
// Register preview gallery for navigation
|
|
||||||
registerGallery('#preview-gallery', '.preview-img');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Character-context toggle (style-specific)
|
||||||
|
const charSelect = document.getElementById('character_select');
|
||||||
|
const charContext = document.getElementById('character-context');
|
||||||
|
charSelect.addEventListener('change', () => {
|
||||||
|
charContext.classList.toggle('d-none', !charSelect.value || charSelect.value === '__random__');
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
{% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %}
|
||||||
<small class="text-muted text-truncate" title="{{ style.data.lora.lora_name }}">{{ lora_name }}</small>
|
<small class="text-muted text-truncate" title="{{ style.data.lora.lora_name }}">{{ lora_name }}</small>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
<button class="btn btn-sm btn-danger py-0 px-1 flex-shrink-0 ms-1 resource-delete-btn" title="Delete"
|
||||||
data-category="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}">🗑</button>
|
data-category="styles" data-slug="{{ style.slug }}" data-name="{{ style.name | e }}"><img src="/static/icons/trash.png" alt="Delete" style="width:16px;height:16px;"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user