Files
character-browser/routes/api.py
Aodhan Collins 7d79e626a5 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>
2026-03-15 21:19:12 +00:00

106 lines
3.7 KiB
Python

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)