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:
@@ -0,0 +1 @@
|
||||
"""live.f12.rocks — event photo pipeline (SFTP inbox -> gmic/rembg -> web)."""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Central configuration, loaded from environment variables.
|
||||
|
||||
Every setting has a sane default so the app runs locally without a `.env`
|
||||
file, but production deploys should set these via `compose.yml` / `.env`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# --- Paths -------------------------------------------------------------
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
|
||||
INBOX_DIR = DATA_DIR / "inbox"
|
||||
JOBS_DIR = DATA_DIR / "jobs"
|
||||
ASSETS_DIR = DATA_DIR / "assets"
|
||||
PROCESSED_FILE = DATA_DIR / "processed.json"
|
||||
|
||||
BACKGROUND_FILE = ASSETS_DIR / "background"
|
||||
FOREGROUND_FILE = ASSETS_DIR / "foreground"
|
||||
BLEND_MODES_FILE = ASSETS_DIR / "blend_modes"
|
||||
FILTERS_JSON = ASSETS_DIR / "filters.json"
|
||||
|
||||
# --- Processing ----------------------------------------------------------
|
||||
OUTPUT_COUNT = _int_env("OUTPUT_COUNT", 3)
|
||||
BLEND_OPACITY = os.environ.get("BLEND_OPACITY", "30%")
|
||||
FILTER_TIMEOUT = _int_env("FILTER_TIMEOUT", 120)
|
||||
MAX_FILTER_ATTEMPTS = _int_env("MAX_FILTER_ATTEMPTS", 8)
|
||||
# Generous on purpose: the very first rembg call also downloads the ~176MB
|
||||
# u2net model, which can take a while depending on the link.
|
||||
REMBG_TIMEOUT = _int_env("REMBG_TIMEOUT", 600)
|
||||
NICE_LEVEL = _int_env("NICE_LEVEL", 18)
|
||||
|
||||
# gmic CLI binary, override for local dev if not on PATH. rembg has no
|
||||
# equivalent here — it runs via `python -m app.rembg_cli` (see pipeline.py
|
||||
# run_rembg / app/rembg_cli.py), not a standalone binary.
|
||||
GMIC_BIN = os.environ.get("GMIC_BIN", "gmic")
|
||||
|
||||
# Fixed post-processing chain applied to every composed variant, ported
|
||||
# verbatim from make_random.py.
|
||||
POST_FILTERS: tuple[str, ...] = (
|
||||
"fx_equalize_local_histograms 75,2,4,100,8,1,0",
|
||||
"fx_map_tones 0.5,0.7,0.1,30,0",
|
||||
"fx_LCE 80,0.5,1,1,0,0",
|
||||
)
|
||||
|
||||
# Opacity choices offered in the remix form (blend "amount" percentages).
|
||||
OPACITY_CHOICES: tuple[str, ...] = (
|
||||
"10%",
|
||||
"20%",
|
||||
"30%",
|
||||
"40%",
|
||||
"50%",
|
||||
"60%",
|
||||
"70%",
|
||||
"80%",
|
||||
"90%",
|
||||
"100%",
|
||||
)
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
# --- Inbox watcher ---------------------------------------------------------
|
||||
POLL_INTERVAL_SECONDS = _float_env("POLL_INTERVAL_SECONDS", 5)
|
||||
STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2)
|
||||
|
||||
# --- Web ---------------------------------------------------------------
|
||||
SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks")
|
||||
+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)
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
"""Image compose pipeline — ported from `make_random.py`.
|
||||
|
||||
Core building blocks (gmic filter application, blending, filter-retry with
|
||||
timeout) are kept as close to the reference script as possible. On top of
|
||||
that this module adds job-directory bookkeeping, kept intermediates, and a
|
||||
manifest format the web app can read/append to (for remix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import config
|
||||
|
||||
logger = logging.getLogger("livef12.pipeline")
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class PipelineError(RuntimeError):
|
||||
"""Raised when a job (or a single variant) cannot be completed."""
|
||||
|
||||
|
||||
class FilterNotFoundError(PipelineError):
|
||||
"""Raised when no working filter could be picked after retries."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterAssets:
|
||||
background_names: list[str]
|
||||
foreground_names: list[str]
|
||||
blend_modes: list[str]
|
||||
commands: dict[str, str]
|
||||
|
||||
|
||||
# --- low-level helpers, ported from make_random.py ------------------------
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def _nice(cmd: list[str]) -> list[str]:
|
||||
if config.NICE_LEVEL <= 0:
|
||||
return cmd
|
||||
return ["nice", "-n", str(config.NICE_LEVEL), *cmd]
|
||||
|
||||
|
||||
def load_lines(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Datei nicht gefunden: {path}")
|
||||
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise ValueError(f"Datei ist leer: {path}")
|
||||
return lines
|
||||
|
||||
|
||||
def load_filter_commands() -> dict[str, str]:
|
||||
data = json.loads(config.FILTERS_JSON.read_text(encoding="utf-8"))
|
||||
return {item["plain_name"]: item["full_command"] for item in data}
|
||||
|
||||
|
||||
def load_assets() -> FilterAssets:
|
||||
"""Load background/foreground/blend-mode lists and cross-check against
|
||||
filters.json, dropping any names without a known command (mirrors the
|
||||
warning+filter behaviour in make_random.py's main())."""
|
||||
bg_names = load_lines(config.BACKGROUND_FILE)
|
||||
fg_names = load_lines(config.FOREGROUND_FILE)
|
||||
blend_modes = load_lines(config.BLEND_MODES_FILE)
|
||||
commands = load_filter_commands()
|
||||
|
||||
missing_bg = [name for name in bg_names if name not in commands]
|
||||
missing_fg = [name for name in fg_names if name not in commands]
|
||||
if missing_bg:
|
||||
logger.warning("%d background filter names have no command, dropping them", len(missing_bg))
|
||||
bg_names = [name for name in bg_names if name in commands]
|
||||
if missing_fg:
|
||||
logger.warning("%d foreground filter names have no command, dropping them", len(missing_fg))
|
||||
fg_names = [name for name in fg_names if name in commands]
|
||||
if not bg_names or not fg_names:
|
||||
raise PipelineError("Keine gueltigen Filter in background/foreground Listen")
|
||||
|
||||
return FilterAssets(background_names=bg_names, foreground_names=fg_names, blend_modes=blend_modes, commands=commands)
|
||||
|
||||
|
||||
def run_gmic(args: list[str], output_image: Path) -> tuple[bool, str, float]:
|
||||
output_image.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = _nice([config.GMIC_BIN, *args, "-o", str(output_image)])
|
||||
start = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.FILTER_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Timeout nach {config.FILTER_TIMEOUT}s", time.monotonic() - start
|
||||
elapsed = time.monotonic() - start
|
||||
if proc.returncode != 0:
|
||||
err = strip_ansi((proc.stderr or proc.stdout or "").strip())
|
||||
err = err.splitlines()[-1] if err else f"Exit code {proc.returncode}"
|
||||
return False, err[:500], elapsed
|
||||
if not output_image.exists() or output_image.stat().st_size == 0:
|
||||
return False, "Keine Ausgabedatei erzeugt", elapsed
|
||||
return True, "", elapsed
|
||||
|
||||
|
||||
def gmic_filter_args(image: Path, full_command: str) -> list[str]:
|
||||
if " " in full_command:
|
||||
name, args = full_command.split(" ", 1)
|
||||
return [str(image), name, args]
|
||||
return [str(image), full_command]
|
||||
|
||||
|
||||
def apply_filter(image: Path, full_command: str, output_image: Path) -> tuple[bool, str]:
|
||||
ok, err, _ = run_gmic(gmic_filter_args(image, full_command), output_image)
|
||||
return ok, err
|
||||
|
||||
|
||||
def apply_filter_chain(image: Path, commands: tuple[str, ...], output_image: Path, tmp_dir: Path, prefix: str) -> tuple[bool, str]:
|
||||
current = image
|
||||
for i, command in enumerate(commands):
|
||||
target = output_image if i == len(commands) - 1 else tmp_dir / f"{prefix}_post_{i}.png"
|
||||
ok, err = apply_filter(current, command, target)
|
||||
if not ok:
|
||||
return False, err
|
||||
current = target
|
||||
return True, ""
|
||||
|
||||
|
||||
def blend_layers(base: Path, overlay: Path, mode: str, output_image: Path) -> tuple[bool, str]:
|
||||
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{config.BLEND_OPACITY}"], output_image)
|
||||
return ok, err
|
||||
|
||||
|
||||
def blend_layers_opacity(base: Path, overlay: Path, mode: str, opacity: str, output_image: Path) -> tuple[bool, str]:
|
||||
ok, err, _ = run_gmic([str(base), str(overlay), "blend", f"{mode},{opacity}"], output_image)
|
||||
return ok, err
|
||||
|
||||
|
||||
def alpha_composite(base: Path, overlay: Path, output_image: Path) -> tuple[bool, str]:
|
||||
ok, err, _ = run_gmic([str(base), str(overlay), "blend", "alpha"], output_image)
|
||||
return ok, err
|
||||
|
||||
|
||||
def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]:
|
||||
output_image.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = _nice([sys.executable, "-m", "app.rembg_cli", str(input_image), str(output_image)])
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=config.REMBG_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"rembg Timeout nach {config.REMBG_TIMEOUT}s"
|
||||
if proc.returncode != 0 or not output_image.exists() or output_image.stat().st_size == 0:
|
||||
lines = strip_ansi((proc.stderr or proc.stdout or "").strip()).splitlines()
|
||||
return False, (lines[-1] if lines else f"Exit code {proc.returncode}")
|
||||
return True, ""
|
||||
|
||||
|
||||
def pick_working_filter(
|
||||
names: list[str],
|
||||
commands: dict[str, str],
|
||||
image: Path,
|
||||
tmp_dir: Path,
|
||||
label: str,
|
||||
rng: random.Random,
|
||||
) -> tuple[str, str]:
|
||||
tried: set[str] = set()
|
||||
for _ in range(config.MAX_FILTER_ATTEMPTS):
|
||||
candidates = [n for n in names if n not in tried]
|
||||
if not candidates:
|
||||
break
|
||||
name = rng.choice(candidates)
|
||||
tried.add(name)
|
||||
command = commands.get(name)
|
||||
if not command:
|
||||
continue
|
||||
probe = tmp_dir / f"probe_{label}_{uuid.uuid4().hex[:8]}.png"
|
||||
ok, err = apply_filter(image, command, probe)
|
||||
probe.unlink(missing_ok=True)
|
||||
if ok:
|
||||
return name, command
|
||||
logger.info("skip %s filter %r: %s", label, name, err)
|
||||
raise FilterNotFoundError(f"Kein funktionierender {label}-Filter nach {config.MAX_FILTER_ATTEMPTS} Versuchen")
|
||||
|
||||
|
||||
# --- job-level orchestration ------------------------------------------------
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def new_job_id() -> str:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return f"{stamp}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobPaths:
|
||||
root: Path
|
||||
original: Path
|
||||
rembg: Path
|
||||
intermediates: Path
|
||||
variants: Path
|
||||
manifest: Path
|
||||
status: Path
|
||||
|
||||
|
||||
def job_paths(job_id: str, original_suffix: str = ".jpg") -> JobPaths:
|
||||
root = config.JOBS_DIR / job_id
|
||||
return JobPaths(
|
||||
root=root,
|
||||
original=root / f"original{original_suffix}",
|
||||
rembg=root / "rembg.png",
|
||||
intermediates=root / "intermediates",
|
||||
variants=root / "variants",
|
||||
manifest=root / "manifest.json",
|
||||
status=root / "status.json",
|
||||
)
|
||||
|
||||
|
||||
def read_status(job_id: str) -> dict[str, Any] | None:
|
||||
paths = job_paths(job_id)
|
||||
if not paths.status.exists():
|
||||
return None
|
||||
return json.loads(paths.status.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
|
||||
data = {}
|
||||
if paths.status.exists():
|
||||
try:
|
||||
data = json.loads(paths.status.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
data = {}
|
||||
data["status"] = status
|
||||
data["updated_at"] = now_iso()
|
||||
data.setdefault("created_at", data["updated_at"])
|
||||
data.update(extra)
|
||||
paths.status.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def read_manifest(job_id: str) -> dict[str, Any] | None:
|
||||
paths = job_paths(job_id)
|
||||
if not paths.manifest.exists():
|
||||
return None
|
||||
return json.loads(paths.manifest.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
|
||||
manifest["updated_at"] = now_iso()
|
||||
paths.manifest.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def next_variant_id(manifest: dict[str, Any]) -> str:
|
||||
existing = {v["id"] for v in manifest.get("variants", [])}
|
||||
i = len(manifest.get("variants", [])) + 1
|
||||
while f"v{i}" in existing:
|
||||
i += 1
|
||||
return f"v{i}"
|
||||
|
||||
|
||||
def compose_variant(
|
||||
paths: JobPaths,
|
||||
assets: FilterAssets,
|
||||
*,
|
||||
variant_id: str,
|
||||
source: str,
|
||||
bg_name: str | None = None,
|
||||
bg_mode: str | None = None,
|
||||
fg_name: str | None = None,
|
||||
fg_mode: str | None = None,
|
||||
opacity: str | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compose one variant image from the job's cached original + rembg.
|
||||
|
||||
If bg_name/fg_name/bg_mode/fg_mode are given (remix path) they are used
|
||||
directly. Otherwise a random working filter is picked with retries,
|
||||
exactly like make_random.py's compose_one().
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
tmp_dir = paths.intermediates
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bg_mode = bg_mode or rng.choice(assets.blend_modes)
|
||||
fg_mode = fg_mode or rng.choice(assets.blend_modes)
|
||||
opacity = opacity or config.BLEND_OPACITY
|
||||
|
||||
if bg_name:
|
||||
bg_command = assets.commands.get(bg_name)
|
||||
if not bg_command:
|
||||
raise PipelineError(f"Unbekannter Background-Filter: {bg_name}")
|
||||
else:
|
||||
bg_name, bg_command = pick_working_filter(assets.background_names, assets.commands, paths.original, tmp_dir, "bg", rng)
|
||||
|
||||
if fg_name:
|
||||
fg_command = assets.commands.get(fg_name)
|
||||
if not fg_command:
|
||||
raise PipelineError(f"Unbekannter Foreground-Filter: {fg_name}")
|
||||
else:
|
||||
fg_name, fg_command = pick_working_filter(assets.foreground_names, assets.commands, paths.rembg, tmp_dir, "fg", rng)
|
||||
|
||||
p = {
|
||||
"bg_filtered": tmp_dir / f"{variant_id}_bg_filtered.png",
|
||||
"step1": tmp_dir / f"{variant_id}_bg_blend.png",
|
||||
"step2": tmp_dir / f"{variant_id}_rembg_alpha.png",
|
||||
"fg_filtered": tmp_dir / f"{variant_id}_fg_filtered.png",
|
||||
"composed": tmp_dir / f"{variant_id}_composed.png",
|
||||
"final": paths.variants / f"{variant_id}.png",
|
||||
}
|
||||
paths.variants.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Background-Filter fehlgeschlagen: {err}")
|
||||
|
||||
ok, err = blend_layers_opacity(paths.original, p["bg_filtered"], bg_mode, opacity, p["step1"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Background-Blend fehlgeschlagen: {err}")
|
||||
|
||||
ok, err = alpha_composite(p["step1"], paths.rembg, p["step2"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Rembg-Alpha fehlgeschlagen: {err}")
|
||||
|
||||
ok, err = apply_filter(paths.rembg, fg_command, p["fg_filtered"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Foreground-Filter fehlgeschlagen: {err}")
|
||||
|
||||
ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"])
|
||||
if not ok:
|
||||
raise PipelineError(f"Foreground-Blend fehlgeschlagen: {err}")
|
||||
|
||||
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id)
|
||||
if not ok:
|
||||
raise PipelineError(f"Post-Processing fehlgeschlagen: {err}")
|
||||
|
||||
rel = lambda path: str(path.relative_to(paths.root))
|
||||
return {
|
||||
"id": variant_id,
|
||||
"source": source,
|
||||
"file": rel(p["final"]),
|
||||
"background_filter": bg_name,
|
||||
"background_command": bg_command,
|
||||
"background_blend": bg_mode,
|
||||
"foreground_filter": fg_name,
|
||||
"foreground_command": fg_command,
|
||||
"foreground_blend": fg_mode,
|
||||
"blend_opacity": opacity,
|
||||
"post_filters": list(config.POST_FILTERS),
|
||||
"created_at": now_iso(),
|
||||
"intermediates": {
|
||||
"bg_filtered": rel(p["bg_filtered"]),
|
||||
"bg_blend": rel(p["step1"]),
|
||||
"rembg_alpha": rel(p["step2"]),
|
||||
"fg_filtered": rel(p["fg_filtered"]),
|
||||
"composed": rel(p["composed"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def process_job(job_id: str, source_path: Path, original_suffix: str) -> None:
|
||||
"""Full pipeline for a freshly ingested inbox file: copy, rembg once,
|
||||
generate OUTPUT_COUNT variants, write manifest + status.
|
||||
|
||||
`source_path` must already be a private copy (jobs/<id>/original.*) —
|
||||
callers (worker.py) are responsible for copying out of inbox first, so
|
||||
the inbox file itself is never touched here.
|
||||
"""
|
||||
paths = job_paths(job_id, original_suffix)
|
||||
paths.root.mkdir(parents=True, exist_ok=True)
|
||||
paths.intermediates.mkdir(parents=True, exist_ok=True)
|
||||
paths.variants.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
write_status(paths, "processing", source_file=str(source_path.name))
|
||||
manifest: dict[str, Any] = {
|
||||
"job_id": job_id,
|
||||
"original_file": paths.original.name,
|
||||
"rembg_file": paths.rembg.name,
|
||||
"created_at": now_iso(),
|
||||
"variants": [],
|
||||
}
|
||||
|
||||
try:
|
||||
ok, err = run_rembg(paths.original, paths.rembg)
|
||||
if not ok:
|
||||
raise PipelineError(f"rembg fehlgeschlagen: {err}")
|
||||
|
||||
assets = load_assets()
|
||||
rng = random.Random()
|
||||
variant_errors: list[str] = []
|
||||
for i in range(1, config.OUTPUT_COUNT + 1):
|
||||
variant_id = f"v{i}"
|
||||
try:
|
||||
entry = compose_variant(paths, assets, variant_id=variant_id, source="auto", rng=rng)
|
||||
manifest["variants"].append(entry)
|
||||
write_manifest(paths, manifest)
|
||||
except PipelineError as exc:
|
||||
logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc)
|
||||
variant_errors.append(f"{variant_id}: {exc}")
|
||||
|
||||
if not manifest["variants"]:
|
||||
raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors))
|
||||
|
||||
write_status(paths, "done", variant_errors=variant_errors)
|
||||
except PipelineError as exc:
|
||||
logger.error("[%s] job failed: %s", job_id, exc)
|
||||
write_status(paths, "error", error=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 - keep the worker loop alive
|
||||
logger.exception("[%s] unexpected error", job_id)
|
||||
write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Minimal `rembg` runner, invoked as its own subprocess.
|
||||
|
||||
We deliberately don't shell out to the official `rembg` CLI: that needs
|
||||
the `rembg[cli]` extra (aiohttp, gradio, watchdog, ...) just to run a
|
||||
single background removal. Calling the `remove()` API directly from a
|
||||
tiny script keeps the image smaller while still giving pipeline.py a
|
||||
subprocess boundary to apply `nice` and a hard timeout to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: python -m app.rembg_cli <input> <output>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from rembg import remove
|
||||
|
||||
input_path = Path(sys.argv[1])
|
||||
output_path = Path(sys.argv[2])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(remove(input_path.read_bytes()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Remix: create one additional variant for an existing job, using the
|
||||
cached original + rembg output and user-chosen filters/blends/opacity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import pipeline
|
||||
from .pipeline import FilterAssets, JobPaths, PipelineError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemixChoice:
|
||||
bg_filter: str
|
||||
bg_blend: str
|
||||
fg_filter: str
|
||||
fg_blend: str
|
||||
opacity: str
|
||||
|
||||
|
||||
def build_remix_options(assets: FilterAssets) -> dict[str, list[str]]:
|
||||
return {
|
||||
"background_filters": sorted(assets.background_names),
|
||||
"foreground_filters": sorted(assets.foreground_names),
|
||||
"blend_modes": sorted(assets.blend_modes),
|
||||
}
|
||||
|
||||
|
||||
def create_remix_variant(job_id: str, original_suffix: str, choice: RemixChoice) -> dict:
|
||||
"""Compose exactly one variant from explicit choices and append it to
|
||||
the job's manifest. Raises PipelineError on failure (caller should show
|
||||
it to the user, no half-written manifest entries are ever created)."""
|
||||
paths: JobPaths = pipeline.job_paths(job_id, original_suffix)
|
||||
if not paths.original.exists() or not paths.rembg.exists():
|
||||
raise PipelineError("Original oder Rembg-Bild fehlt fuer diesen Job — Remix nicht moeglich.")
|
||||
|
||||
assets = pipeline.load_assets()
|
||||
if choice.bg_filter not in assets.commands:
|
||||
raise PipelineError(f"Unbekannter Background-Filter: {choice.bg_filter}")
|
||||
if choice.fg_filter not in assets.commands:
|
||||
raise PipelineError(f"Unbekannter Foreground-Filter: {choice.fg_filter}")
|
||||
if choice.bg_blend not in assets.blend_modes or choice.fg_blend not in assets.blend_modes:
|
||||
raise PipelineError("Unbekannter Blend-Modus.")
|
||||
|
||||
manifest = pipeline.read_manifest(job_id) or {
|
||||
"job_id": job_id,
|
||||
"original_file": paths.original.name,
|
||||
"rembg_file": paths.rembg.name,
|
||||
"created_at": pipeline.now_iso(),
|
||||
"variants": [],
|
||||
}
|
||||
variant_id = pipeline.next_variant_id(manifest)
|
||||
|
||||
entry = pipeline.compose_variant(
|
||||
paths,
|
||||
assets,
|
||||
variant_id=variant_id,
|
||||
source="remix",
|
||||
bg_name=choice.bg_filter,
|
||||
bg_mode=choice.bg_blend,
|
||||
fg_name=choice.fg_filter,
|
||||
fg_mode=choice.fg_blend,
|
||||
opacity=choice.opacity,
|
||||
)
|
||||
manifest["variants"].append(entry)
|
||||
pipeline.write_manifest(paths, manifest)
|
||||
return entry
|
||||
@@ -0,0 +1,328 @@
|
||||
/* live.f12.rocks — mobile-first, dark, utilitarian. No slop. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #14161a;
|
||||
--bg-elevated: #1d2025;
|
||||
--border: #2c3038;
|
||||
--text: #e7e9ec;
|
||||
--text-dim: #9aa0aa;
|
||||
--accent: #ff5a1f;
|
||||
--accent-text: #14161a;
|
||||
--ok: #4caf6a;
|
||||
--err: #e05a4a;
|
||||
--pending: #d9a441;
|
||||
--radius: 8px;
|
||||
--gap: 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
padding: 1rem var(--gap);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: var(--gap);
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-dim);
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: rgba(224, 90, 74, 0.15);
|
||||
border: 1px solid var(--err);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
display: inline-block;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-done { color: var(--ok); border-color: var(--ok); }
|
||||
.status-error { color: var(--err); border-color: var(--err); }
|
||||
.status-processing,
|
||||
.status-pending { color: var(--pending); border-color: var(--pending); }
|
||||
|
||||
/* --- grids --- */
|
||||
|
||||
.job-grid,
|
||||
.variant-grid,
|
||||
.intermediate-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.intermediate-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.job-grid,
|
||||
.variant-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.intermediate-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.job-grid,
|
||||
.variant-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.job-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-card a {
|
||||
color: var(--text);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.job-card .thumb,
|
||||
.variant-card img,
|
||||
.pair-grid img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.job-id {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.job-status.status-processing,
|
||||
.job-status.status-pending,
|
||||
.job-status.status-done,
|
||||
.job-status.status-error,
|
||||
.job-status.status-unknown {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.pair-grid {
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.pair-grid figure {
|
||||
margin: 0;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pair-grid figcaption {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.variant-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.variant-meta {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.variant-meta .tag {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.variant-meta dl {
|
||||
margin: 0.4rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.15rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.variant-meta dt {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intermediate-grid li {
|
||||
list-style: none;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.intermediate-grid img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* --- section header with action button --- */
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- buttons & forms --- */
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
min-height: 2.75rem;
|
||||
line-height: 1.6rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remix-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.remix-form label {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.remix-form select,
|
||||
.remix-form button {
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.remix-form button {
|
||||
margin-top: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ site_title }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/">{{ site_title }}</a>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ site_title }}{% endblock %}
|
||||
{% block content %}
|
||||
{% if not jobs %}
|
||||
<p class="empty-state">Noch keine Jobs. Bild per SFTP hochladen, dann kurz warten.</p>
|
||||
{% else %}
|
||||
<ul class="job-grid">
|
||||
{% for job in jobs %}
|
||||
<li class="job-card">
|
||||
<a href="/jobs/{{ job.job_id }}">
|
||||
{% if job.thumbnail %}
|
||||
<img class="thumb" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
|
||||
{% else %}
|
||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
||||
{% endif %}
|
||||
<span class="job-meta">
|
||||
<span class="job-id">{{ job.job_id }}</span>
|
||||
<span class="job-status status-{{ job.status }}">{{ job.status }} · {{ job.variant_count }} Varianten</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Job {{ job_id }} — {{ site_title }}{% endblock %}
|
||||
{% block content %}
|
||||
<p class="back-link"><a href="/">← Alle Jobs</a></p>
|
||||
|
||||
<h1>Job {{ job_id }}</h1>
|
||||
<p class="job-status status-{{ status.status }}">Status: {{ status.status or "unbekannt" }}</p>
|
||||
{% if status.error %}
|
||||
<p class="error-box">Fehler: {{ status.error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>Original & Rembg</h2>
|
||||
<div class="pair-grid">
|
||||
{% if original_name %}
|
||||
<figure>
|
||||
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
||||
<figcaption>
|
||||
Original ·
|
||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if has_rembg %}
|
||||
<figure>
|
||||
<img src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
|
||||
<figcaption>
|
||||
Rembg ·
|
||||
<a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-header">
|
||||
<h2>Varianten</h2>
|
||||
{% if has_rembg %}
|
||||
<a class="button" href="/jobs/{{ job_id }}/remix">+ Remix</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not manifest.variants %}
|
||||
<p class="empty-state">Noch keine Varianten fertig.</p>
|
||||
{% else %}
|
||||
<ul class="variant-grid">
|
||||
{% for v in manifest.variants %}
|
||||
<li class="variant-card">
|
||||
<img src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
|
||||
<div class="variant-meta">
|
||||
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
||||
<dl>
|
||||
<dt>BG</dt><dd>{{ v.background_filter }} + {{ v.background_blend }}</dd>
|
||||
<dt>FG</dt><dd>{{ v.foreground_filter }} + {{ v.foreground_blend }}</dd>
|
||||
<dt>Opacity</dt><dd>{{ v.blend_opacity }}</dd>
|
||||
</dl>
|
||||
<a href="/jobs/{{ job_id }}/files/{{ v.file }}" download>Download</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if intermediates %}
|
||||
<section>
|
||||
<h2>Zwischenschritte</h2>
|
||||
<ul class="intermediate-grid">
|
||||
{% for name in intermediates %}
|
||||
<li>
|
||||
<img src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
|
||||
<span>{{ name }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %}
|
||||
{% block content %}
|
||||
<p class="back-link"><a href="/jobs/{{ job_id }}">← Zurueck zum Job</a></p>
|
||||
|
||||
<h1>Remix · Job {{ job_id }}</h1>
|
||||
|
||||
{% if error %}
|
||||
<p class="error-box">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/jobs/{{ job_id }}/remix" class="remix-form">
|
||||
<label for="bg_filter">Background-Filter</label>
|
||||
<select name="bg_filter" id="bg_filter" required>
|
||||
{% for name in options.background_filters %}
|
||||
<option value="{{ name }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="bg_blend">Background-Blend</label>
|
||||
<select name="bg_blend" id="bg_blend" required>
|
||||
{% for mode in options.blend_modes %}
|
||||
<option value="{{ mode }}">{{ mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="fg_filter">Foreground-Filter</label>
|
||||
<select name="fg_filter" id="fg_filter" required>
|
||||
{% for name in options.foreground_filters %}
|
||||
<option value="{{ name }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="fg_blend">Foreground-Blend</label>
|
||||
<select name="fg_blend" id="fg_blend" required>
|
||||
{% for mode in options.blend_modes %}
|
||||
<option value="{{ mode }}">{{ mode }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="opacity">Blend-Opacity</label>
|
||||
<select name="opacity" id="opacity" required>
|
||||
{% for value in opacity_choices %}
|
||||
<option value="{{ value }}" {% if value == "30%" %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button type="submit" class="button button-primary">Remix erzeugen</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
"""Inbox watcher + sequential job runner.
|
||||
|
||||
Polls `DATA_DIR/inbox` for new, size-stable image files, copies each one
|
||||
into its own `jobs/<job_id>/original.*` and runs the compose pipeline on
|
||||
it. Files are NEVER deleted or moved from the inbox — `processed.json`
|
||||
tracks what has already been handled (by path + size + mtime) so restarts
|
||||
don't reprocess everything.
|
||||
|
||||
Runs one job at a time (no threading) — this is intentional: the compose
|
||||
pipeline is CPU heavy (gmic/rembg) and the worker container is capped at
|
||||
`cpus: "1.0"` in compose.yml, so concurrency would only cause thrashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import config, pipeline
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("livef12.worker")
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for path in (config.INBOX_DIR, config.JOBS_DIR):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_processed() -> dict[str, Any]:
|
||||
if not config.PROCESSED_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(config.PROCESSED_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
logger.warning("processed.json unreadable, starting fresh")
|
||||
return {}
|
||||
|
||||
|
||||
def save_processed(processed: dict[str, Any]) -> None:
|
||||
tmp = config.PROCESSED_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(processed, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(config.PROCESSED_FILE)
|
||||
|
||||
|
||||
def _file_key(path: Path) -> str:
|
||||
return str(path.relative_to(config.INBOX_DIR))
|
||||
|
||||
|
||||
def _is_already_processed(processed: dict[str, Any], path: Path, stat: Any) -> bool:
|
||||
entry = processed.get(_file_key(path))
|
||||
if not entry:
|
||||
return False
|
||||
return entry.get("size") == stat.st_size and entry.get("mtime") == stat.st_mtime
|
||||
|
||||
|
||||
def _is_stable(path: Path) -> bool:
|
||||
"""A file is "stable" if its size doesn't change across a short wait —
|
||||
cheap way to avoid picking up a half-uploaded SFTP transfer."""
|
||||
try:
|
||||
size_before = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
time.sleep(config.STABLE_WAIT_SECONDS)
|
||||
try:
|
||||
size_after = path.stat().st_size
|
||||
except OSError:
|
||||
return False
|
||||
return size_before == size_after and size_after > 0
|
||||
|
||||
|
||||
def find_new_files(processed: dict[str, Any]) -> list[Path]:
|
||||
if not config.INBOX_DIR.exists():
|
||||
return []
|
||||
candidates: list[Path] = []
|
||||
for path in sorted(config.INBOX_DIR.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in config.SUPPORTED_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if _is_already_processed(processed, path, stat):
|
||||
continue
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
|
||||
def handle_file(path: Path, processed: dict[str, Any]) -> None:
|
||||
if not _is_stable(path):
|
||||
logger.info("skip %s: still being written", path.name)
|
||||
return
|
||||
|
||||
stat = path.stat()
|
||||
job_id = pipeline.new_job_id()
|
||||
suffix = path.suffix.lower()
|
||||
paths = pipeline.job_paths(job_id, suffix)
|
||||
paths.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("new inbox file %s -> job %s", path.name, job_id)
|
||||
# Copy (never move) so the inbox stays untouched.
|
||||
shutil.copy2(path, paths.original)
|
||||
|
||||
try:
|
||||
pipeline.process_job(job_id, paths.original, suffix)
|
||||
finally:
|
||||
# Mark as processed regardless of pipeline outcome so a permanently
|
||||
# broken image doesn't get retried forever; failures are visible in
|
||||
# jobs/<id>/status.json for manual follow-up.
|
||||
processed[_file_key(path)] = {
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"job_id": job_id,
|
||||
"processed_at": pipeline.now_iso(),
|
||||
}
|
||||
save_processed(processed)
|
||||
|
||||
|
||||
def scan_once(processed: dict[str, Any]) -> None:
|
||||
for path in find_new_files(processed):
|
||||
try:
|
||||
handle_file(path, processed)
|
||||
except Exception: # noqa: BLE001 - one bad file must not kill the loop
|
||||
logger.exception("failed to handle %s", path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_dirs()
|
||||
processed = load_processed()
|
||||
logger.info(
|
||||
"worker started, watching %s (output_count=%d, nice=%d)",
|
||||
config.INBOX_DIR,
|
||||
config.OUTPUT_COUNT,
|
||||
config.NICE_LEVEL,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
scan_once(processed)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("scan cycle failed")
|
||||
time.sleep(config.POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user