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:
Frank Schwenk
2026-07-16 21:32:10 +02:00
commit 90192cd284
31 changed files with 7739 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""live.f12.rocks — event photo pipeline (SFTP inbox -> gmic/rembg -> web)."""
+83
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+31
View File
@@ -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())
+67
View File
@@ -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
+328
View File
@@ -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;
}
+17
View File
@@ -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>
+25
View File
@@ -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 }} &middot; {{ job.variant_count }} Varianten</span>
</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Job {{ job_id }} — {{ site_title }}{% endblock %}
{% block content %}
<p class="back-link"><a href="/">&larr; 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 &amp; Rembg</h2>
<div class="pair-grid">
{% if original_name %}
<figure>
<img src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
<figcaption>
Original &middot;
<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 &middot;
<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 %}
+50
View File
@@ -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 }}">&larr; Zurueck zum Job</a></p>
<h1>Remix &middot; 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
View File
@@ -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())