feat: initial live.f12.rocks SFTP → gmic/rembg → web pipeline
Event pep stack with SFTPGo inbox, sequential worker, FastAPI gallery/remix, and Traefik-ready compose. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
"""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, 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) -> 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)
|
||||
return templates.TemplateResponse(
|
||||
"remix.html",
|
||||
{
|
||||
"request": request,
|
||||
"site_title": config.SITE_TITLE,
|
||||
"job_id": job_id,
|
||||
"options": options,
|
||||
"opacity_choices": config.OPACITY_CHOICES,
|
||||
"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)
|
||||
Reference in New Issue
Block a user