diff --git a/API_GUIDE.md b/API_GUIDE.md new file mode 100644 index 0000000..8f28866 --- /dev/null +++ b/API_GUIDE.md @@ -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: ` +- **Query parameter:** `?api_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/ +``` + +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/ +``` + +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://: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']}") +``` diff --git a/CLAUDE.md b/CLAUDE.md index 64a30b7..711ea76 100644 --- a/CLAUDE.md +++ b/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//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/` — 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/` — 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//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///` 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//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". --- diff --git a/app.py b/app.py index 2cf131b..703c453 100644 --- a/app.py +++ b/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: diff --git a/models.py b/models.py index 5ca7e65..ff67a24 100644 --- a/models.py +++ b/models.py @@ -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 '' diff --git a/routes/__init__.py b/routes/__init__.py index 3cee25c..64c65ef 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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) diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..e0c1a74 --- /dev/null +++ b/routes/api.py @@ -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/', 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/') + @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) diff --git a/routes/presets.py b/routes/presets.py index 0594e09..0e797bf 100644 --- a/routes/presets.py +++ b/routes/presets.py @@ -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 diff --git a/routes/settings.py b/routes/settings.py index d3d1eca..d781dab 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -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}) diff --git a/services/generation.py b/services/generation.py new file mode 100644 index 0000000..590cd90 --- /dev/null +++ b/services/generation.py @@ -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 diff --git a/static/style.css b/static/style.css index f4f6668..b72bb57 100644 --- a/static/style.css +++ b/static/style.css @@ -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 { diff --git a/templates/actions/index.html b/templates/actions/index.html index 3a42a53..c5c14c8 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -29,9 +29,15 @@ {{ action.name }} No Image {% else %} + {% set fallback = random_gen_image('actions', action.slug) %} + {% if fallback %} + {{ action.name }} + No Image + {% else %} {{ action.name }} No Image {% endif %} + {% endif %}
{{ action.name }}
diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index d4c7661..c87a9f2 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -28,9 +28,15 @@ {{ ckpt.name }} No Image {% else %} + {% set fallback = random_gen_image('checkpoints', ckpt.slug) %} + {% if fallback %} + {{ ckpt.name }} + No Image + {% else %} {{ ckpt.name }} No Image {% endif %} + {% endif %}
{{ ckpt.name }}
diff --git a/templates/detailers/index.html b/templates/detailers/index.html index 41f9772..40014af 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -29,9 +29,15 @@ {{ detailer.name }} No Image {% else %} + {% set fallback = random_gen_image('detailers', detailer.slug) %} + {% if fallback %} + {{ detailer.name }} + No Image + {% else %} {{ detailer.name }} No Image {% endif %} + {% endif %}
{{ detailer.name }}
diff --git a/templates/index.html b/templates/index.html index 307a8d6..e6cba36 100644 --- a/templates/index.html +++ b/templates/index.html @@ -22,9 +22,15 @@ {{ char.name }} No Image {% else %} + {% set fallback = random_gen_image('characters', char.slug) %} + {% if fallback %} + {{ char.name }} + No Image + {% else %} {{ char.name }} No Image {% endif %} + {% endif %}
{{ char.name }}
diff --git a/templates/looks/index.html b/templates/looks/index.html index 5c3a5e4..9479bd5 100644 --- a/templates/looks/index.html +++ b/templates/looks/index.html @@ -29,9 +29,15 @@ {{ look.name }} No Image {% else %} + {% set fallback = random_gen_image('looks', look.slug) %} + {% if fallback %} + {{ look.name }} + No Image + {% else %} {{ look.name }} No Image {% endif %} + {% endif %} {% if look_assignments.get(look.look_id, 0) > 0 %} {{ look_assignments.get(look.look_id, 0) }} {% endif %} diff --git a/templates/outfits/index.html b/templates/outfits/index.html index be61ed8..71b65a2 100644 --- a/templates/outfits/index.html +++ b/templates/outfits/index.html @@ -29,9 +29,15 @@ {{ outfit.name }} No Image {% else %} + {% set fallback = random_gen_image('outfits', outfit.slug) %} + {% if fallback %} + {{ outfit.name }} + No Image + {% else %} {{ outfit.name }} No Image {% endif %} + {% endif %} {% if outfit.data.lora and outfit.data.lora.lora_name and lora_assignments.get(outfit.data.lora.lora_name, 0) > 0 %} {{ lora_assignments.get(outfit.data.lora.lora_name, 0) }} {% endif %} diff --git a/templates/presets/index.html b/templates/presets/index.html index c251e61..c84fbae 100644 --- a/templates/presets/index.html +++ b/templates/presets/index.html @@ -27,8 +27,13 @@ {% if preset.image_path %} {{ preset.name }} {% else %} + {% set fallback = random_gen_image('presets', preset.slug) %} + {% if fallback %} + {{ preset.name }} + {% else %} No Image {% endif %} + {% endif %}
{{ preset.name }}
diff --git a/templates/scenes/index.html b/templates/scenes/index.html index 66f2a33..90f6b4f 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -29,9 +29,15 @@ {{ scene.name }} No Image {% else %} + {% set fallback = random_gen_image('scenes', scene.slug) %} + {% if fallback %} + {{ scene.name }} + No Image + {% else %} {{ scene.name }} No Image {% endif %} + {% endif %}
{{ scene.name }}
diff --git a/templates/settings.html b/templates/settings.html index ec35a8e..31d96c6 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -141,6 +141,25 @@
+ +
+ +
REST API Key
+

Generate an API key to use the /api/v1/ endpoints for programmatic image generation.

+
+
+ + + + +
+
@@ -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 = ' Hide'; + } else { + apiKeyDisplay.type = 'password'; + apiKeyToggle.innerHTML = ' Show'; + } + }); + } + + if (apiKeyCopy) { + apiKeyCopy.addEventListener('click', () => { + if (apiKeyDisplay.value) { + navigator.clipboard.writeText(apiKeyDisplay.value); + apiKeyCopy.innerHTML = ' Copied'; + setTimeout(() => { apiKeyCopy.innerHTML = ' 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 = ' 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'); diff --git a/templates/styles/index.html b/templates/styles/index.html index 277333b..2dc7099 100644 --- a/templates/styles/index.html +++ b/templates/styles/index.html @@ -29,9 +29,15 @@ {{ style.name }} No Image {% else %} + {% set fallback = random_gen_image('styles', style.slug) %} + {% if fallback %} + {{ style.name }} + No Image + {% else %} {{ style.name }} No Image {% endif %} + {% endif %}
{{ style.name }}