Add REST API for preset-based generation and fallback cover images
REST API (routes/api.py): Three endpoints behind API key auth for programmatic image generation via presets — list presets, queue generation with optional overrides, and poll job status. Shared generation logic extracted from routes/presets.py into services/generation.py so both web UI and API use the same code path. Fallback covers: library index pages now show a random generated image at reduced opacity when no cover is assigned, instead of "No Image". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
219
API_GUIDE.md
Normal file
219
API_GUIDE.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# GAZE REST API Guide
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open **Settings** in the GAZE web UI
|
||||
2. Scroll to **REST API Key** and click **Regenerate**
|
||||
3. Copy the key — you'll need it for all API requests
|
||||
|
||||
## Authentication
|
||||
|
||||
Every request must include your API key via one of:
|
||||
|
||||
- **Header (recommended):** `X-API-Key: <your-key>`
|
||||
- **Query parameter:** `?api_key=<your-key>`
|
||||
|
||||
Responses for auth failures:
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `401` | Missing API key |
|
||||
| `403` | Invalid API key |
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Presets
|
||||
|
||||
```
|
||||
GET /api/v1/presets
|
||||
```
|
||||
|
||||
Returns all available presets.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"presets": [
|
||||
{
|
||||
"preset_id": "example_01",
|
||||
"slug": "example_01",
|
||||
"name": "Example Preset",
|
||||
"has_cover": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Image
|
||||
|
||||
```
|
||||
POST /api/v1/generate/<preset_slug>
|
||||
```
|
||||
|
||||
Queue one or more image generations using a preset's configuration. All body parameters are optional — when omitted, the preset's own settings are used.
|
||||
|
||||
**Request body (JSON):**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `count` | int | `1` | Number of images to generate (1–20) |
|
||||
| `checkpoint` | string | — | Override checkpoint path (e.g. `"Illustrious/model.safetensors"`) |
|
||||
| `extra_positive` | string | `""` | Additional positive prompt tags appended to the generated prompt |
|
||||
| `extra_negative` | string | `""` | Additional negative prompt tags |
|
||||
| `seed` | int | random | Fixed seed for reproducible generation |
|
||||
| `width` | int | — | Output width in pixels (must provide both width and height) |
|
||||
| `height` | int | — | Output height in pixels (must provide both width and height) |
|
||||
|
||||
**Response (202):**
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [
|
||||
{ "job_id": "783f0268-ba85-4426-8ca2-6393c844c887", "status": "queued" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
|
||||
| Status | Cause |
|
||||
|--------|-------|
|
||||
| `400` | Invalid parameters (bad count, seed, or mismatched width/height) |
|
||||
| `404` | Preset slug not found |
|
||||
| `500` | Internal generation error |
|
||||
|
||||
### Check Job Status
|
||||
|
||||
```
|
||||
GET /api/v1/job/<job_id>
|
||||
```
|
||||
|
||||
Poll this endpoint to track generation progress.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "783f0268-ba85-4426-8ca2-6393c844c887",
|
||||
"label": "Preset: Example Preset – preview",
|
||||
"status": "done",
|
||||
"error": null,
|
||||
"result": {
|
||||
"image_url": "/static/uploads/presets/example_01/gen_1773601346.png",
|
||||
"relative_path": "presets/example_01/gen_1773601346.png",
|
||||
"seed": 927640517599332
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Job statuses:**
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Waiting in queue |
|
||||
| `processing` | Currently generating |
|
||||
| `done` | Complete — `result` contains image info |
|
||||
| `failed` | Error occurred — check `error` field |
|
||||
|
||||
The `result` object is only present when status is `done`. Use `seed` from the result to reproduce the exact same image later.
|
||||
|
||||
**Retrieving the image:** The `image_url` is a path relative to the server root. Fetch it directly:
|
||||
|
||||
```
|
||||
GET http://<host>:5782/static/uploads/presets/example_01/gen_1773601346.png
|
||||
```
|
||||
|
||||
Image retrieval does not require authentication.
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate a single image and wait for it
|
||||
|
||||
```bash
|
||||
API_KEY="your-key-here"
|
||||
HOST="http://localhost:5782"
|
||||
|
||||
# Queue generation
|
||||
JOB_ID=$(curl -s -X POST \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
"$HOST/api/v1/generate/example_01" | python3 -c "import sys,json; print(json.load(sys.stdin)['jobs'][0]['job_id'])")
|
||||
|
||||
echo "Job: $JOB_ID"
|
||||
|
||||
# Poll until done
|
||||
while true; do
|
||||
RESULT=$(curl -s -H "X-API-Key: $API_KEY" "$HOST/api/v1/job/$JOB_ID")
|
||||
STATUS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
|
||||
echo "Status: $STATUS"
|
||||
if [ "$STATUS" = "done" ] || [ "$STATUS" = "failed" ]; then
|
||||
echo "$RESULT" | python3 -m json.tool
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
### Generate 3 images with extra prompts
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"count": 3,
|
||||
"extra_positive": "smiling, outdoors",
|
||||
"extra_negative": "blurry"
|
||||
}' \
|
||||
"$HOST/api/v1/generate/example_01"
|
||||
```
|
||||
|
||||
### Reproduce a specific image
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"seed": 927640517599332}' \
|
||||
"$HOST/api/v1/generate/example_01"
|
||||
```
|
||||
|
||||
### Python example
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
HOST = "http://localhost:5782"
|
||||
API_KEY = "your-key-here"
|
||||
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
|
||||
|
||||
# List presets
|
||||
presets = requests.get(f"{HOST}/api/v1/presets", headers=HEADERS).json()
|
||||
print(f"Available presets: {[p['name'] for p in presets['presets']]}")
|
||||
|
||||
# Generate
|
||||
resp = requests.post(
|
||||
f"{HOST}/api/v1/generate/{presets['presets'][0]['slug']}",
|
||||
headers=HEADERS,
|
||||
json={"count": 1},
|
||||
).json()
|
||||
|
||||
job_id = resp["jobs"][0]["job_id"]
|
||||
print(f"Queued job: {job_id}")
|
||||
|
||||
# Poll
|
||||
while True:
|
||||
status = requests.get(f"{HOST}/api/v1/job/{job_id}", headers=HEADERS).json()
|
||||
print(f"Status: {status['status']}")
|
||||
if status["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
if status["status"] == "done":
|
||||
image_url = f"{HOST}{status['result']['image_url']}"
|
||||
print(f"Image: {image_url}")
|
||||
print(f"Seed: {status['result']['seed']}")
|
||||
```
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -27,6 +27,7 @@ services/
|
||||
sync.py # All sync_*() functions + preset resolution helpers
|
||||
job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread)
|
||||
file_io.py # LoRA/checkpoint scanning, file helpers
|
||||
generation.py # Shared generation logic (generate_from_preset)
|
||||
routes/
|
||||
__init__.py # register_routes(app) — imports and calls all route modules
|
||||
characters.py # Character CRUD + generation + outfit management
|
||||
@@ -44,6 +45,7 @@ routes/
|
||||
strengths.py # Strengths gallery system
|
||||
transfer.py # Resource transfer system
|
||||
queue_api.py # /api/queue/* endpoints
|
||||
api.py # REST API v1 (preset generation, auth)
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
@@ -60,7 +62,8 @@ app.py
|
||||
│ ├── mcp.py ← (stdlib only: subprocess, os)
|
||||
│ ├── sync.py ← models, utils
|
||||
│ ├── job_queue.py ← comfyui, models
|
||||
│ └── file_io.py ← models, utils
|
||||
│ ├── file_io.py ← models, utils
|
||||
│ └── generation.py ← prompts, workflow, job_queue, sync, models
|
||||
└── routes/
|
||||
├── All route modules ← services/*, utils, models
|
||||
└── (routes never import from other routes)
|
||||
@@ -77,6 +80,8 @@ SQLite at `instance/database.db`, managed by Flask-SQLAlchemy. The DB is a cache
|
||||
|
||||
**Models**: `Character`, `Look`, `Outfit`, `Action`, `Style`, `Scene`, `Detailer`, `Checkpoint`, `Settings`
|
||||
|
||||
The `Settings` model stores LLM provider config, LoRA/checkpoint directory paths, default checkpoint, and `api_key` for REST API authentication.
|
||||
|
||||
All category models (except Settings and Checkpoint) share this pattern:
|
||||
- `{entity}_id` — canonical ID (from JSON, often matches filename without extension)
|
||||
- `slug` — URL-safe version of the ID (alphanumeric + underscores only, via `re.sub(r'[^a-zA-Z0-9_]', '', id)`)
|
||||
@@ -180,6 +185,10 @@ LoRA nodes chain: `4 → 16 → 17 → 18 → 19`. Unused LoRA nodes are bypasse
|
||||
- **`get_available_checkpoints()`** — Scans checkpoint directories.
|
||||
- **`_count_look_assignments()`** / **`_count_outfit_lora_assignments()`** — DB aggregate queries.
|
||||
|
||||
### `services/generation.py` — Shared Generation Logic
|
||||
|
||||
- **`generate_from_preset(preset, overrides=None)`** — Core preset generation function used by both the web route and the REST API. Resolves entities, builds prompts, wires the workflow, and enqueues the job. The `overrides` dict accepts: `action`, `checkpoint`, `extra_positive`, `extra_negative`, `seed`, `width`, `height`. Has no `request` or `session` dependencies.
|
||||
|
||||
### `services/mcp.py` — MCP/Docker Lifecycle
|
||||
|
||||
- **`ensure_mcp_server_running()`** — Ensures the danbooru-mcp Docker container is running.
|
||||
@@ -351,6 +360,15 @@ Each category follows the same URL pattern:
|
||||
- `POST /checkpoint/<slug>/save_json`
|
||||
- `POST /checkpoints/rescan`
|
||||
|
||||
### REST API (`/api/v1/`)
|
||||
Authenticated via `X-API-Key` header (or `api_key` query param). Key is stored in `Settings.api_key` and managed from the Settings page.
|
||||
- `GET /api/v1/presets` — list all presets (id, slug, name, has_cover)
|
||||
- `POST /api/v1/generate/<preset_slug>` — queue generation from a preset; accepts JSON body with optional `checkpoint`, `extra_positive`, `extra_negative`, `seed`, `width`, `height`, `count` (1–20); returns `{"jobs": [{"job_id": ..., "status": "queued"}]}`
|
||||
- `GET /api/v1/job/<job_id>` — poll job status; returns `{"id", "label", "status", "error", "result"}`
|
||||
- `POST /api/key/regenerate` — generate a new API key (Settings page)
|
||||
|
||||
See `API_GUIDE.md` for full usage examples.
|
||||
|
||||
### Job Queue API
|
||||
All generation routes use the background job queue. Frontend polls:
|
||||
- `GET /api/queue/<job_id>/status` — returns `{"status": "pending"|"running"|"done"|"failed", "result": {...}}`
|
||||
@@ -378,7 +396,7 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there
|
||||
- Global default checkpoint selector (saves to session via AJAX)
|
||||
- Resource delete modal (soft/hard) shared across gallery pages
|
||||
- `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs)
|
||||
- Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template.
|
||||
- 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.
|
||||
- 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.
|
||||
- **Batch generation** (library pages): Uses a two-phase pattern:
|
||||
@@ -386,6 +404,7 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there
|
||||
2. **Poll phase**: All jobs are polled concurrently via `Promise.all()`, updating UI as each completes
|
||||
3. **Progress tracking**: Displays currently processing items in real-time using a `Set` to track active jobs
|
||||
4. **Sorting**: All batch operations sort items by display `name` (not `filename`) for better UX
|
||||
- **Fallback covers** (library pages): When a resource has no assigned `image_path` but has generated images in its upload folder, a random image is shown at 50% opacity (CSS class `fallback-cover`). The image changes on each page load. Resources with no generations show "No Image".
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
app.py
1
app.py
@@ -106,6 +106,7 @@ if __name__ == '__main__':
|
||||
('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"),
|
||||
('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"),
|
||||
('default_checkpoint', "VARCHAR(500)"),
|
||||
('api_key', "VARCHAR(255)"),
|
||||
]
|
||||
for col_name, col_type in columns_to_add:
|
||||
try:
|
||||
|
||||
@@ -291,6 +291,8 @@ class Settings(db.Model):
|
||||
checkpoint_dirs = db.Column(db.String(1000), default='/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob')
|
||||
# Default checkpoint path (persisted across server restarts)
|
||||
default_checkpoint = db.Column(db.String(500), nullable=True)
|
||||
# API key for REST API authentication
|
||||
api_key = db.Column(db.String(255), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Settings>'
|
||||
|
||||
@@ -17,6 +17,7 @@ def register_routes(app):
|
||||
from routes import gallery
|
||||
from routes import strengths
|
||||
from routes import transfer
|
||||
from routes import api
|
||||
|
||||
queue_api.register_routes(app)
|
||||
settings.register_routes(app)
|
||||
@@ -35,3 +36,4 @@ def register_routes(app):
|
||||
gallery.register_routes(app)
|
||||
strengths.register_routes(app)
|
||||
transfer.register_routes(app)
|
||||
api.register_routes(app)
|
||||
|
||||
105
routes/api.py
Normal file
105
routes/api.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
from models import Preset, Settings
|
||||
from services.generation import generate_from_preset
|
||||
from services.job_queue import _job_queue_lock, _job_history
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
def require_api_key(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||
if not api_key:
|
||||
return jsonify({'error': 'Missing API key. Provide X-API-Key header or api_key query parameter.'}), 401
|
||||
settings = Settings.query.first()
|
||||
if not settings or not settings.api_key or api_key != settings.api_key:
|
||||
return jsonify({'error': 'Invalid API key.'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@app.route('/api/v1/presets')
|
||||
@require_api_key
|
||||
def api_v1_presets():
|
||||
presets = Preset.query.order_by(Preset.name).all()
|
||||
return jsonify({
|
||||
'presets': [
|
||||
{
|
||||
'preset_id': p.preset_id,
|
||||
'slug': p.slug,
|
||||
'name': p.name,
|
||||
'has_cover': bool(p.image_path),
|
||||
}
|
||||
for p in presets
|
||||
]
|
||||
})
|
||||
|
||||
@app.route('/api/v1/generate/<path:preset_slug>', methods=['POST'])
|
||||
@require_api_key
|
||||
def api_v1_generate(preset_slug):
|
||||
preset = Preset.query.filter_by(slug=preset_slug).first()
|
||||
if not preset:
|
||||
return jsonify({'error': f'Preset not found: {preset_slug}'}), 404
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
count = body.get('count', 1)
|
||||
try:
|
||||
count = int(count)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'count must be an integer'}), 400
|
||||
if count < 1 or count > 20:
|
||||
return jsonify({'error': 'count must be between 1 and 20'}), 400
|
||||
|
||||
seed = body.get('seed')
|
||||
if seed is not None:
|
||||
try:
|
||||
seed = int(seed)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'seed must be an integer'}), 400
|
||||
|
||||
width = body.get('width')
|
||||
height = body.get('height')
|
||||
if (width is None) != (height is None):
|
||||
return jsonify({'error': 'width and height must both be provided or both omitted'}), 400
|
||||
|
||||
overrides = {
|
||||
'action': 'preview',
|
||||
'checkpoint': body.get('checkpoint', ''),
|
||||
'extra_positive': body.get('extra_positive', ''),
|
||||
'extra_negative': body.get('extra_negative', ''),
|
||||
'seed': seed,
|
||||
'width': int(width) if width else None,
|
||||
'height': int(height) if height else None,
|
||||
}
|
||||
|
||||
try:
|
||||
jobs = []
|
||||
for _ in range(count):
|
||||
job = generate_from_preset(preset, overrides)
|
||||
jobs.append({'job_id': job['id'], 'status': 'queued'})
|
||||
return jsonify({'jobs': jobs}), 202
|
||||
except Exception as e:
|
||||
logger.exception("API generation error (preset %s): %s", preset_slug, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/v1/job/<job_id>')
|
||||
@require_api_key
|
||||
def api_v1_job_status(job_id):
|
||||
with _job_queue_lock:
|
||||
job = _job_history.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
resp = {
|
||||
'id': job['id'],
|
||||
'label': job['label'],
|
||||
'status': job['status'],
|
||||
'error': job['error'],
|
||||
}
|
||||
if job.get('result'):
|
||||
resp['result'] = job['result']
|
||||
return jsonify(resp)
|
||||
@@ -11,6 +11,7 @@ from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ens
|
||||
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.llm import load_prompt, call_llm
|
||||
from utils import allowed_file
|
||||
|
||||
@@ -39,180 +40,30 @@ def register_routes(app):
|
||||
|
||||
try:
|
||||
action = request.form.get('action', 'preview')
|
||||
|
||||
# Get additional prompts
|
||||
extra_positive = request.form.get('extra_positive', '').strip()
|
||||
extra_negative = request.form.get('extra_negative', '').strip()
|
||||
|
||||
# Save to session (web UI state)
|
||||
session[f'extra_pos_preset_{slug}'] = extra_positive
|
||||
session[f'extra_neg_preset_{slug}'] = extra_negative
|
||||
session.modified = True
|
||||
|
||||
data = preset.data
|
||||
|
||||
# Resolve entities
|
||||
char_cfg = data.get('character', {})
|
||||
character = _resolve_preset_entity('character', char_cfg.get('character_id'))
|
||||
if not character:
|
||||
character = Character.query.order_by(db.func.random()).first()
|
||||
|
||||
outfit_cfg = data.get('outfit', {})
|
||||
action_cfg = data.get('action', {})
|
||||
style_cfg = data.get('style', {})
|
||||
scene_cfg = data.get('scene', {})
|
||||
detailer_cfg = data.get('detailer', {})
|
||||
look_cfg = data.get('look', {})
|
||||
ckpt_cfg = data.get('checkpoint', {})
|
||||
|
||||
outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id'))
|
||||
action_obj = _resolve_preset_entity('action', action_cfg.get('action_id'))
|
||||
style_obj = _resolve_preset_entity('style', style_cfg.get('style_id'))
|
||||
scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id'))
|
||||
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
||||
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
||||
|
||||
# Checkpoint: form override > preset config > session default
|
||||
checkpoint_override = request.form.get('checkpoint_override', '').strip()
|
||||
if checkpoint_override:
|
||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint_override).first()
|
||||
ckpt_path = checkpoint_override
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
else:
|
||||
preset_ckpt = ckpt_cfg.get('checkpoint_path')
|
||||
if preset_ckpt == 'random':
|
||||
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
|
||||
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
elif preset_ckpt:
|
||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
|
||||
ckpt_path = preset_ckpt
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
else:
|
||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||
|
||||
# Resolve selected fields from preset toggles
|
||||
selected_fields = _resolve_preset_fields(data)
|
||||
|
||||
# Build combined data for prompt building
|
||||
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
|
||||
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
|
||||
if wardrobe_source is None:
|
||||
wardrobe_source = character.get_active_wardrobe() if character else {}
|
||||
|
||||
combined_data = {
|
||||
'character_id': character.character_id if character else 'unknown',
|
||||
'identity': character.data.get('identity', {}) if character else {},
|
||||
'defaults': character.data.get('defaults', {}) if character else {},
|
||||
'wardrobe': wardrobe_source,
|
||||
'styles': character.data.get('styles', {}) if character else {},
|
||||
'lora': (look_obj.data.get('lora', {}) if look_obj
|
||||
else (character.data.get('lora', {}) if character else {})),
|
||||
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
|
||||
}
|
||||
|
||||
# Build extras prompt from secondary resources
|
||||
extras_parts = []
|
||||
if action_obj:
|
||||
action_fields = action_cfg.get('fields', {})
|
||||
from utils import _BODY_GROUP_KEYS
|
||||
for key in _BODY_GROUP_KEYS:
|
||||
val_cfg = action_fields.get(key, True)
|
||||
if val_cfg == 'random':
|
||||
val_cfg = random.choice([True, False])
|
||||
if val_cfg:
|
||||
val = action_obj.data.get('action', {}).get(key, '')
|
||||
if val:
|
||||
extras_parts.append(val)
|
||||
if action_cfg.get('use_lora', True):
|
||||
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(action_obj.data.get('tags', []))
|
||||
if style_obj:
|
||||
s = style_obj.data.get('style', {})
|
||||
if s.get('artist_name'):
|
||||
extras_parts.append(f"by {s['artist_name']}")
|
||||
if s.get('artistic_style'):
|
||||
extras_parts.append(s['artistic_style'])
|
||||
if style_cfg.get('use_lora', True):
|
||||
trg = style_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
if scene_obj:
|
||||
scene_fields = scene_cfg.get('fields', {})
|
||||
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
|
||||
val_cfg = scene_fields.get(key, True)
|
||||
if val_cfg == 'random':
|
||||
val_cfg = random.choice([True, False])
|
||||
if val_cfg:
|
||||
val = scene_obj.data.get('scene', {}).get(key, '')
|
||||
if val:
|
||||
extras_parts.append(val)
|
||||
if scene_cfg.get('use_lora', True):
|
||||
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(scene_obj.data.get('tags', []))
|
||||
if detailer_obj:
|
||||
prompt_val = detailer_obj.data.get('prompt', '')
|
||||
if isinstance(prompt_val, list):
|
||||
extras_parts.extend(p for p in prompt_val if p)
|
||||
elif prompt_val:
|
||||
extras_parts.append(prompt_val)
|
||||
if detailer_cfg.get('use_lora', True):
|
||||
trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
prompts = build_prompt(combined_data, selected_fields, default_fields=None,
|
||||
active_outfit=active_wardrobe)
|
||||
if extras_parts:
|
||||
extra_str = ', '.join(filter(None, extras_parts))
|
||||
prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str)
|
||||
|
||||
if extra_positive:
|
||||
prompts["main"] = f"{prompts['main']}, {extra_positive}"
|
||||
|
||||
# Parse optional seed
|
||||
# Build overrides from form
|
||||
seed_val = request.form.get('seed', '').strip()
|
||||
fixed_seed = int(seed_val) if seed_val else None
|
||||
|
||||
# Resolution: form override > preset config > workflow default
|
||||
res_cfg = data.get('resolution', {})
|
||||
form_width = request.form.get('width', '').strip()
|
||||
form_height = request.form.get('height', '').strip()
|
||||
if form_width and form_height:
|
||||
gen_width = int(form_width)
|
||||
gen_height = int(form_height)
|
||||
elif res_cfg.get('random', False):
|
||||
_RES_OPTIONS = [
|
||||
(1024, 1024), (1152, 896), (896, 1152), (1344, 768),
|
||||
(768, 1344), (1280, 800), (800, 1280),
|
||||
]
|
||||
gen_width, gen_height = random.choice(_RES_OPTIONS)
|
||||
else:
|
||||
gen_width = res_cfg.get('width') or None
|
||||
gen_height = res_cfg.get('height') or None
|
||||
|
||||
workflow = _prepare_workflow(
|
||||
workflow, character, prompts,
|
||||
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
|
||||
custom_negative=extra_negative or None,
|
||||
outfit=outfit if outfit_cfg.get('use_lora', True) else None,
|
||||
action=action_obj if action_cfg.get('use_lora', True) else None,
|
||||
style=style_obj if style_cfg.get('use_lora', True) else None,
|
||||
scene=scene_obj if scene_cfg.get('use_lora', True) else None,
|
||||
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
|
||||
look=look_obj,
|
||||
fixed_seed=fixed_seed,
|
||||
width=gen_width,
|
||||
height=gen_height,
|
||||
)
|
||||
overrides = {
|
||||
'action': action,
|
||||
'extra_positive': extra_positive,
|
||||
'extra_negative': extra_negative,
|
||||
'checkpoint': request.form.get('checkpoint_override', '').strip(),
|
||||
'seed': int(seed_val) if seed_val else None,
|
||||
'width': int(form_width) if form_width else None,
|
||||
'height': int(form_height) if form_height else None,
|
||||
}
|
||||
|
||||
label = f"Preset: {preset.name} – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('presets', slug, Preset, action))
|
||||
job = generate_from_preset(preset, overrides)
|
||||
|
||||
session[f'preview_preset_{slug}'] = None
|
||||
session.modified = True
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
from flask import flash, redirect, render_template, request, session, url_for
|
||||
from flask import flash, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from models import Checkpoint, Settings, db
|
||||
|
||||
@@ -12,6 +15,19 @@ logger = logging.getLogger('gaze')
|
||||
|
||||
def register_routes(app):
|
||||
|
||||
@app.template_global()
|
||||
def random_gen_image(category, slug):
|
||||
"""Return a random generated image path for a resource, or None."""
|
||||
folder = os.path.join(app.config['UPLOAD_FOLDER'], category, slug)
|
||||
try:
|
||||
images = [f for f in os.listdir(folder)
|
||||
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not images:
|
||||
return None
|
||||
return f"{category}/{slug}/{random.choice(images)}"
|
||||
|
||||
@app.context_processor
|
||||
def inject_comfyui_ws_url():
|
||||
url = app.config.get('COMFYUI_URL', 'http://127.0.0.1:8188')
|
||||
@@ -227,3 +243,14 @@ def register_routes(app):
|
||||
return redirect(url_for('settings'))
|
||||
|
||||
return render_template('settings.html', settings=settings)
|
||||
|
||||
@app.route('/api/key/regenerate', methods=['POST'])
|
||||
def regenerate_api_key():
|
||||
settings = Settings.query.first()
|
||||
if not settings:
|
||||
settings = Settings()
|
||||
db.session.add(settings)
|
||||
settings.api_key = secrets.token_hex(32)
|
||||
db.session.commit()
|
||||
logger.info("API key regenerated")
|
||||
return jsonify({'api_key': settings.api_key})
|
||||
|
||||
200
services/generation.py
Normal file
200
services/generation.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import json
|
||||
import random
|
||||
import logging
|
||||
|
||||
from models import db, Character, Checkpoint, Preset
|
||||
from services.prompts import build_prompt, _dedup_tags
|
||||
from services.workflow import _prepare_workflow, _get_default_checkpoint
|
||||
from services.job_queue import _enqueue_job, _make_finalize
|
||||
from services.sync import _resolve_preset_entity, _resolve_preset_fields
|
||||
|
||||
logger = logging.getLogger('gaze')
|
||||
|
||||
|
||||
def generate_from_preset(preset, overrides=None):
|
||||
"""Execute preset-based generation.
|
||||
|
||||
Args:
|
||||
preset: Preset ORM object
|
||||
overrides: optional dict with keys:
|
||||
checkpoint, extra_positive, extra_negative, seed, width, height, action
|
||||
|
||||
Returns:
|
||||
job dict from _enqueue_job()
|
||||
"""
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
|
||||
action = overrides.get('action', 'preview')
|
||||
extra_positive = overrides.get('extra_positive', '').strip()
|
||||
extra_negative = overrides.get('extra_negative', '').strip()
|
||||
|
||||
data = preset.data
|
||||
|
||||
# Resolve entities
|
||||
char_cfg = data.get('character', {})
|
||||
character = _resolve_preset_entity('character', char_cfg.get('character_id'))
|
||||
if not character:
|
||||
character = Character.query.order_by(db.func.random()).first()
|
||||
|
||||
outfit_cfg = data.get('outfit', {})
|
||||
action_cfg = data.get('action', {})
|
||||
style_cfg = data.get('style', {})
|
||||
scene_cfg = data.get('scene', {})
|
||||
detailer_cfg = data.get('detailer', {})
|
||||
look_cfg = data.get('look', {})
|
||||
ckpt_cfg = data.get('checkpoint', {})
|
||||
|
||||
outfit = _resolve_preset_entity('outfit', outfit_cfg.get('outfit_id'))
|
||||
action_obj = _resolve_preset_entity('action', action_cfg.get('action_id'))
|
||||
style_obj = _resolve_preset_entity('style', style_cfg.get('style_id'))
|
||||
scene_obj = _resolve_preset_entity('scene', scene_cfg.get('scene_id'))
|
||||
detailer_obj = _resolve_preset_entity('detailer', detailer_cfg.get('detailer_id'))
|
||||
look_obj = _resolve_preset_entity('look', look_cfg.get('look_id'))
|
||||
|
||||
# Checkpoint: override > preset config > default
|
||||
checkpoint_override = overrides.get('checkpoint', '').strip() if overrides.get('checkpoint') else ''
|
||||
if checkpoint_override:
|
||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=checkpoint_override).first()
|
||||
ckpt_path = checkpoint_override
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
else:
|
||||
preset_ckpt = ckpt_cfg.get('checkpoint_path')
|
||||
if preset_ckpt == 'random':
|
||||
ckpt_obj = Checkpoint.query.order_by(db.func.random()).first()
|
||||
ckpt_path = ckpt_obj.checkpoint_path if ckpt_obj else None
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
elif preset_ckpt:
|
||||
ckpt_obj = Checkpoint.query.filter_by(checkpoint_path=preset_ckpt).first()
|
||||
ckpt_path = preset_ckpt
|
||||
ckpt_data = ckpt_obj.data if ckpt_obj else None
|
||||
else:
|
||||
ckpt_path, ckpt_data = _get_default_checkpoint()
|
||||
|
||||
# Resolve selected fields from preset toggles
|
||||
selected_fields = _resolve_preset_fields(data)
|
||||
|
||||
# Build combined data for prompt building
|
||||
active_wardrobe = char_cfg.get('fields', {}).get('wardrobe', {}).get('outfit', 'default')
|
||||
wardrobe_source = outfit.data.get('wardrobe', {}) if outfit else None
|
||||
if wardrobe_source is None:
|
||||
wardrobe_source = character.get_active_wardrobe() if character else {}
|
||||
|
||||
combined_data = {
|
||||
'character_id': character.character_id if character else 'unknown',
|
||||
'identity': character.data.get('identity', {}) if character else {},
|
||||
'defaults': character.data.get('defaults', {}) if character else {},
|
||||
'wardrobe': wardrobe_source,
|
||||
'styles': character.data.get('styles', {}) if character else {},
|
||||
'lora': (look_obj.data.get('lora', {}) if look_obj
|
||||
else (character.data.get('lora', {}) if character else {})),
|
||||
'tags': (character.data.get('tags', []) if character else []) + data.get('tags', []),
|
||||
}
|
||||
|
||||
# Build extras prompt from secondary resources
|
||||
extras_parts = []
|
||||
if action_obj:
|
||||
action_fields = action_cfg.get('fields', {})
|
||||
from utils import _BODY_GROUP_KEYS
|
||||
for key in _BODY_GROUP_KEYS:
|
||||
val_cfg = action_fields.get(key, True)
|
||||
if val_cfg == 'random':
|
||||
val_cfg = random.choice([True, False])
|
||||
if val_cfg:
|
||||
val = action_obj.data.get('action', {}).get(key, '')
|
||||
if val:
|
||||
extras_parts.append(val)
|
||||
if action_cfg.get('use_lora', True):
|
||||
trg = action_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(action_obj.data.get('tags', []))
|
||||
if style_obj:
|
||||
s = style_obj.data.get('style', {})
|
||||
if s.get('artist_name'):
|
||||
extras_parts.append(f"by {s['artist_name']}")
|
||||
if s.get('artistic_style'):
|
||||
extras_parts.append(s['artistic_style'])
|
||||
if style_cfg.get('use_lora', True):
|
||||
trg = style_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
if scene_obj:
|
||||
scene_fields = scene_cfg.get('fields', {})
|
||||
for key in ['background', 'foreground', 'furniture', 'colors', 'lighting', 'theme']:
|
||||
val_cfg = scene_fields.get(key, True)
|
||||
if val_cfg == 'random':
|
||||
val_cfg = random.choice([True, False])
|
||||
if val_cfg:
|
||||
val = scene_obj.data.get('scene', {}).get(key, '')
|
||||
if val:
|
||||
extras_parts.append(val)
|
||||
if scene_cfg.get('use_lora', True):
|
||||
trg = scene_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
extras_parts.extend(scene_obj.data.get('tags', []))
|
||||
if detailer_obj:
|
||||
prompt_val = detailer_obj.data.get('prompt', '')
|
||||
if isinstance(prompt_val, list):
|
||||
extras_parts.extend(p for p in prompt_val if p)
|
||||
elif prompt_val:
|
||||
extras_parts.append(prompt_val)
|
||||
if detailer_cfg.get('use_lora', True):
|
||||
trg = detailer_obj.data.get('lora', {}).get('lora_triggers', '')
|
||||
if trg:
|
||||
extras_parts.append(trg)
|
||||
|
||||
with open('comfy_workflow.json', 'r') as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
prompts = build_prompt(combined_data, selected_fields, default_fields=None,
|
||||
active_outfit=active_wardrobe)
|
||||
if extras_parts:
|
||||
extra_str = ', '.join(filter(None, extras_parts))
|
||||
prompts['main'] = _dedup_tags(f"{prompts['main']}, {extra_str}" if prompts['main'] else extra_str)
|
||||
|
||||
if extra_positive:
|
||||
prompts["main"] = f"{prompts['main']}, {extra_positive}"
|
||||
|
||||
# Parse optional seed
|
||||
fixed_seed = overrides.get('seed')
|
||||
if fixed_seed is not None:
|
||||
fixed_seed = int(fixed_seed)
|
||||
|
||||
# Resolution: override > preset config > workflow default
|
||||
res_cfg = data.get('resolution', {})
|
||||
override_width = overrides.get('width')
|
||||
override_height = overrides.get('height')
|
||||
if override_width and override_height:
|
||||
gen_width = int(override_width)
|
||||
gen_height = int(override_height)
|
||||
elif res_cfg.get('random', False):
|
||||
_RES_OPTIONS = [
|
||||
(1024, 1024), (1152, 896), (896, 1152), (1344, 768),
|
||||
(768, 1344), (1280, 800), (800, 1280),
|
||||
]
|
||||
gen_width, gen_height = random.choice(_RES_OPTIONS)
|
||||
else:
|
||||
gen_width = res_cfg.get('width') or None
|
||||
gen_height = res_cfg.get('height') or None
|
||||
|
||||
workflow = _prepare_workflow(
|
||||
workflow, character, prompts,
|
||||
checkpoint=ckpt_path, checkpoint_data=ckpt_data,
|
||||
custom_negative=extra_negative or None,
|
||||
outfit=outfit if outfit_cfg.get('use_lora', True) else None,
|
||||
action=action_obj if action_cfg.get('use_lora', True) else None,
|
||||
style=style_obj if style_cfg.get('use_lora', True) else None,
|
||||
scene=scene_obj if scene_cfg.get('use_lora', True) else None,
|
||||
detailer=detailer_obj if detailer_cfg.get('use_lora', True) else None,
|
||||
look=look_obj,
|
||||
fixed_seed=fixed_seed,
|
||||
width=gen_width,
|
||||
height=gen_height,
|
||||
)
|
||||
|
||||
label = f"Preset: {preset.name} – {action}"
|
||||
job = _enqueue_job(label, workflow, _make_finalize('presets', preset.slug, Preset, action))
|
||||
|
||||
return job
|
||||
@@ -372,6 +372,13 @@ h5, h6 { color: var(--text); }
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.img-container img.fallback-cover {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.character-card:hover .img-container img.fallback-cover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Assignment badge — shows count of characters using this resource */
|
||||
.assignment-badge {
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ action.slug }}" src="{{ url_for('static', filename='uploads/' + action.image_path) }}" alt="{{ action.name }}">
|
||||
<span id="no-img-{{ action.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('actions', action.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ action.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ action.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ action.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ action.slug }}" src="" alt="{{ action.name }}" class="d-none">
|
||||
<span id="no-img-{{ action.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ action.name }}</h5>
|
||||
|
||||
@@ -28,9 +28,15 @@
|
||||
<img id="img-{{ ckpt.slug }}" src="{{ url_for('static', filename='uploads/' + ckpt.image_path) }}" alt="{{ ckpt.name }}">
|
||||
<span id="no-img-{{ ckpt.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('checkpoints', ckpt.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ ckpt.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ ckpt.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ ckpt.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ ckpt.slug }}" src="" alt="{{ ckpt.name }}" class="d-none">
|
||||
<span id="no-img-{{ ckpt.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ ckpt.name }}</h5>
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ detailer.slug }}" src="{{ url_for('static', filename='uploads/' + detailer.image_path) }}" alt="{{ detailer.name }}">
|
||||
<span id="no-img-{{ detailer.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('detailers', detailer.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ detailer.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ detailer.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ detailer.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ detailer.slug }}" src="" alt="{{ detailer.name }}" class="d-none">
|
||||
<span id="no-img-{{ detailer.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ detailer.name }}</h5>
|
||||
|
||||
@@ -22,9 +22,15 @@
|
||||
<img id="img-{{ char.slug }}" src="{{ url_for('static', filename='uploads/' + char.image_path) }}" alt="{{ char.name }}">
|
||||
<span id="no-img-{{ char.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('characters', char.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ char.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ char.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ char.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ char.slug }}" src="" alt="{{ char.name }}" class="d-none">
|
||||
<span id="no-img-{{ char.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ char.name }}</h5>
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ look.slug }}" src="{{ url_for('static', filename='uploads/' + look.image_path) }}" alt="{{ look.name }}">
|
||||
<span id="no-img-{{ look.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('looks', look.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ look.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ look.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ look.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ look.slug }}" src="" alt="{{ look.name }}" class="d-none">
|
||||
<span id="no-img-{{ look.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if look_assignments.get(look.look_id, 0) > 0 %}
|
||||
<span class="assignment-badge" title="Assigned to {{ look_assignments.get(look.look_id, 0) }} character(s)">{{ look_assignments.get(look.look_id, 0) }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ outfit.slug }}" src="{{ url_for('static', filename='uploads/' + outfit.image_path) }}" alt="{{ outfit.name }}">
|
||||
<span id="no-img-{{ outfit.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('outfits', outfit.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ outfit.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ outfit.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ outfit.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ outfit.slug }}" src="" alt="{{ outfit.name }}" class="d-none">
|
||||
<span id="no-img-{{ outfit.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if outfit.data.lora and outfit.data.lora.lora_name and lora_assignments.get(outfit.data.lora.lora_name, 0) > 0 %}
|
||||
<span class="assignment-badge" title="Assigned to {{ lora_assignments.get(outfit.data.lora.lora_name, 0) }} character(s)">{{ lora_assignments.get(outfit.data.lora.lora_name, 0) }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -27,8 +27,13 @@
|
||||
{% if preset.image_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + preset.image_path) }}" alt="{{ preset.name }}">
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('presets', preset.slug) %}
|
||||
{% if fallback %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ preset.name }}" class="fallback-cover">
|
||||
{% else %}
|
||||
<span class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title text-center mb-1">{{ preset.name }}</h6>
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ scene.slug }}" src="{{ url_for('static', filename='uploads/' + scene.image_path) }}" alt="{{ scene.name }}">
|
||||
<span id="no-img-{{ scene.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('scenes', scene.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ scene.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ scene.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ scene.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ scene.slug }}" src="" alt="{{ scene.name }}" class="d-none">
|
||||
<span id="no-img-{{ scene.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ scene.name }}</h5>
|
||||
|
||||
@@ -141,6 +141,25 @@
|
||||
<button type="submit" class="btn btn-primary btn-lg">Save All Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5 class="mb-3 text-primary">REST API Key</h5>
|
||||
<p class="text-muted small">Generate an API key to use the <code>/api/v1/</code> endpoints for programmatic image generation.</p>
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control font-monospace" id="api-key-display"
|
||||
value="{{ settings.api_key or '' }}" readonly
|
||||
placeholder="No API key generated yet">
|
||||
<button class="btn btn-outline-secondary" type="button" id="api-key-toggle" title="Show/hide key">
|
||||
<i class="bi bi-eye"></i> Show
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" id="api-key-copy" title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" type="button" id="api-key-regen">Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,6 +251,54 @@
|
||||
});
|
||||
}
|
||||
|
||||
// API Key Management
|
||||
const apiKeyDisplay = document.getElementById('api-key-display');
|
||||
const apiKeyToggle = document.getElementById('api-key-toggle');
|
||||
const apiKeyCopy = document.getElementById('api-key-copy');
|
||||
const apiKeyRegen = document.getElementById('api-key-regen');
|
||||
|
||||
if (apiKeyToggle) {
|
||||
apiKeyToggle.addEventListener('click', () => {
|
||||
if (apiKeyDisplay.type === 'password') {
|
||||
apiKeyDisplay.type = 'text';
|
||||
apiKeyToggle.innerHTML = '<i class="bi bi-eye-slash"></i> Hide';
|
||||
} else {
|
||||
apiKeyDisplay.type = 'password';
|
||||
apiKeyToggle.innerHTML = '<i class="bi bi-eye"></i> Show';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKeyCopy) {
|
||||
apiKeyCopy.addEventListener('click', () => {
|
||||
if (apiKeyDisplay.value) {
|
||||
navigator.clipboard.writeText(apiKeyDisplay.value);
|
||||
apiKeyCopy.innerHTML = '<i class="bi bi-check"></i> Copied';
|
||||
setTimeout(() => { apiKeyCopy.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (apiKeyRegen) {
|
||||
apiKeyRegen.addEventListener('click', async () => {
|
||||
if (!confirm('Generate a new API key? Any existing key will stop working.')) return;
|
||||
apiKeyRegen.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/key/regenerate', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.api_key) {
|
||||
apiKeyDisplay.value = data.api_key;
|
||||
apiKeyDisplay.type = 'text';
|
||||
apiKeyToggle.innerHTML = '<i class="bi bi-eye-slash"></i> Hide';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to regenerate API key.');
|
||||
} finally {
|
||||
apiKeyRegen.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Local Model Loading
|
||||
const connectLocalBtn = document.getElementById('connect-local-btn');
|
||||
const localModelSelect = document.getElementById('local_model');
|
||||
|
||||
@@ -29,9 +29,15 @@
|
||||
<img id="img-{{ style.slug }}" src="{{ url_for('static', filename='uploads/' + style.image_path) }}" alt="{{ style.name }}">
|
||||
<span id="no-img-{{ style.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
{% set fallback = random_gen_image('styles', style.slug) %}
|
||||
{% if fallback %}
|
||||
<img id="img-{{ style.slug }}" src="{{ url_for('static', filename='uploads/' + fallback) }}" alt="{{ style.name }}" class="fallback-cover">
|
||||
<span id="no-img-{{ style.slug }}" class="text-muted d-none">No Image</span>
|
||||
{% else %}
|
||||
<img id="img-{{ style.slug }}" src="" alt="{{ style.name }}" class="d-none">
|
||||
<span id="no-img-{{ style.slug }}" class="text-muted">No Image</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center">{{ style.name }}</h5>
|
||||
|
||||
Reference in New Issue
Block a user