Files
livef12rocks/app/main.py
T
Frank Schwenk 83d4468b69 feat: harden pipeline UX — preprocess, rembg alpha, remix/lightbox
Downscale to 2000px JPEG before rembg, random blend opacity, timeout
retries, per-variant remix prefill, lightbox, and longer SFTP idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 23:35:06 +02:00

199 lines
7.0 KiB
Python

"""FastAPI web frontend: browse jobs, view variants/intermediates, remix."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import config, pipeline, remix
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE)
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _validate_job_id(job_id: str) -> str:
if not _JOB_ID_RE.match(job_id):
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
return job_id
def _job_root(job_id: str) -> Path:
root = (config.JOBS_DIR / _validate_job_id(job_id)).resolve()
jobs_dir = config.JOBS_DIR.resolve()
if root.parent != jobs_dir or not root.is_dir():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return root
def _safe_job_file(job_id: str, rel_path: str) -> Path:
root = _job_root(job_id)
candidate = (root / rel_path).resolve()
if root not in candidate.parents and candidate != root:
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
if not candidate.is_file():
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
return candidate
def _original_suffix(job_root: Path) -> str:
for child in job_root.glob("original.*"):
return child.suffix
return ".jpg"
def list_jobs() -> list[dict[str, Any]]:
if not config.JOBS_DIR.exists():
return []
jobs = []
for job_dir in config.JOBS_DIR.iterdir():
if not job_dir.is_dir():
continue
job_id = job_dir.name
status = pipeline.read_status(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {}
jobs.append(
{
"job_id": job_id,
"status": status.get("status", "unknown"),
"created_at": status.get("created_at") or manifest.get("created_at") or "",
"variant_count": len(manifest.get("variants", [])),
"thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None,
}
)
jobs.sort(key=lambda j: j["job_id"], reverse=True)
return jobs
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
"index.html",
{"request": request, "jobs": list_jobs(), "site_title": config.SITE_TITLE},
)
@app.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(request: Request, job_id: str) -> HTMLResponse:
job_root = _job_root(job_id)
status = pipeline.read_status(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {"variants": []}
original = next(iter(job_root.glob("original.*")), None)
rembg_file = job_root / "rembg.png"
intermediates = sorted((job_root / "intermediates").glob("*.png")) if (job_root / "intermediates").exists() else []
return templates.TemplateResponse(
"job.html",
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"status": status,
"manifest": manifest,
"original_name": original.name if original else None,
"has_rembg": rembg_file.exists(),
"intermediates": [p.name for p in intermediates],
},
)
@app.get("/jobs/{job_id}/files/{rel_path:path}")
def job_file(job_id: str, rel_path: str) -> FileResponse:
path = _safe_job_file(job_id, rel_path)
return FileResponse(path)
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
def remix_form(
request: Request,
job_id: str,
error: str | None = None,
from_variant: str | None = Query(None, alias="from"),
) -> HTMLResponse:
job_root = _job_root(job_id)
if not (job_root / "rembg.png").exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
assets = pipeline.load_assets()
options = remix.build_remix_options(assets)
prefill: dict[str, str] = {
"bg_filter": "",
"bg_blend": "",
"fg_filter": "",
"fg_blend": "",
"opacity": config.BLEND_OPACITY if config.BLEND_OPACITY in config.OPACITY_CHOICES else "30%",
}
if from_variant:
manifest = pipeline.read_manifest(job_id) or {}
match = next((v for v in manifest.get("variants", []) if v.get("id") == from_variant), None)
if match:
opacity = match.get("blend_opacity") or prefill["opacity"]
if opacity not in config.OPACITY_CHOICES:
# Snap odd random values (e.g. 37%) to nearest offered choice.
try:
pct = int(str(opacity).rstrip("%"))
nearest = min(
config.OPACITY_CHOICES,
key=lambda c: abs(int(c.rstrip("%")) - pct),
)
opacity = nearest
except ValueError:
opacity = prefill["opacity"]
prefill = {
"bg_filter": match.get("background_filter") or "",
"bg_blend": match.get("background_blend") or "",
"fg_filter": match.get("foreground_filter") or "",
"fg_blend": match.get("foreground_blend") or "",
"opacity": opacity,
}
return templates.TemplateResponse(
"remix.html",
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"options": options,
"opacity_choices": config.OPACITY_CHOICES,
"prefill": prefill,
"from_variant": from_variant,
"error": error,
},
)
@app.post("/jobs/{job_id}/remix")
def remix_submit(
job_id: str,
bg_filter: str = Form(...),
bg_blend: str = Form(...),
fg_filter: str = Form(...),
fg_blend: str = Form(...),
opacity: str = Form(...),
) -> RedirectResponse:
job_root = _job_root(job_id)
suffix = _original_suffix(job_root)
choice = remix.RemixChoice(bg_filter=bg_filter, bg_blend=bg_blend, fg_filter=fg_filter, fg_blend=fg_blend, opacity=opacity)
try:
entry = remix.create_remix_variant(job_id, suffix, choice)
logger.info("[%s] remix created variant %s", job_id, entry["id"])
except pipeline.PipelineError as exc:
logger.warning("[%s] remix failed: %s", job_id, exc)
return RedirectResponse(url=f"/jobs/{job_id}/remix?error={quote(str(exc))}", status_code=303)
return RedirectResponse(url=f"/jobs/{job_id}", status_code=303)