feat: flatten output into variants/ and intermediates/

Drop per-job subdirs for phone-friendly Syncthing layout with
stem-suffixed filenames; keep web state in meta/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-18 14:52:47 +02:00
parent 9a9a3e3afe
commit b925b151f0
11 changed files with 289 additions and 220 deletions
+5 -1
View File
@@ -27,7 +27,11 @@ def _float_env(name: str, default: float) -> float:
# --- Paths -------------------------------------------------------------
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
INBOX_DIR = DATA_DIR / "incoming"
JOBS_DIR = DATA_DIR / "jobs"
VARIANTS_DIR = DATA_DIR / "variants"
INTERMEDIATES_DIR = DATA_DIR / "intermediates"
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via
# Syncthing .stignore if desired.
META_DIR = DATA_DIR / "meta"
ASSETS_DIR = DATA_DIR / "assets"
PROCESSED_FILE = DATA_DIR / "processed.json"
+58 -53
View File
@@ -24,47 +24,45 @@ 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_-]+$")
# Job id = sanitized source stem (may include dots).
_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):
stem = pipeline.sanitize_stem(job_id)
if not _JOB_ID_RE.match(stem):
raise HTTPException(status_code=400, detail="Ungueltige Job-ID")
return job_id
return stem
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():
def _job_exists(job_id: str) -> str:
stem = _validate_job_id(job_id)
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return root
return stem
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:
def _safe_job_file(job_id: str, filename: str) -> Path:
"""Resolve a basename under variants/ or intermediates/ for this stem."""
stem = _validate_job_id(job_id)
name = Path(filename).name
if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
raise HTTPException(status_code=400, detail="Ungueltiger Pfad")
if not name.startswith(f"{stem}_"):
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"
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
candidate = (directory / name).resolve()
if directory.resolve() not in candidate.parents and candidate != directory.resolve():
continue
if candidate.is_file():
return candidate
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
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
for job_id in pipeline.list_job_ids():
status = pipeline.read_status(job_id) or {}
manifest = pipeline.read_manifest(job_id) or {}
jobs.append(
@@ -76,7 +74,7 @@ def list_jobs() -> list[dict[str, Any]]:
"thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None,
}
)
jobs.sort(key=lambda j: j["job_id"], reverse=True)
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
return jobs
@@ -90,31 +88,33 @@ def index(request: Request) -> HTMLResponse:
@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 []
stem = _job_exists(job_id)
paths = pipeline.job_paths(stem)
status = pipeline.read_status(stem) or {}
manifest = pipeline.read_manifest(stem) or {"variants": []}
intermediates = sorted(
p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()
) if paths.intermediates.exists() else []
return templates.TemplateResponse(
"job.html",
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"job_id": stem,
"status": status,
"manifest": manifest,
"original_name": original.name if original else None,
"has_rembg": rembg_file.exists(),
"intermediates": [p.name for p in intermediates],
"original_name": paths.original.name if paths.original.exists() else None,
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
"has_rembg": paths.rembg.exists(),
"intermediates": 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)
@app.get("/jobs/{job_id}/files/{filename:path}")
def job_file(job_id: str, filename: str) -> FileResponse:
path = _safe_job_file(job_id, filename)
return FileResponse(path)
@@ -125,8 +125,9 @@ def remix_form(
error: str | None = None,
from_variant: str | None = Query(None, alias="from"),
) -> HTMLResponse:
job_root = _job_root(job_id)
if not (job_root / "rembg.png").exists():
stem = _job_exists(job_id)
paths = pipeline.job_paths(stem)
if not paths.rembg.exists():
raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.")
assets = pipeline.load_assets()
@@ -139,12 +140,11 @@ def remix_form(
"opacity": config.BLEND_OPACITY if config.BLEND_OPACITY in config.OPACITY_CHOICES else "30%",
}
if from_variant:
manifest = pipeline.read_manifest(job_id) or {}
manifest = pipeline.read_manifest(stem) or {}
match = next((v for v in manifest.get("variants", []) if v.get("id") == from_variant), None)
if match:
opacity = match.get("blend_opacity") or prefill["opacity"]
if opacity not in config.OPACITY_CHOICES:
# Snap odd random values (e.g. 37%) to nearest offered choice.
try:
pct = int(str(opacity).rstrip("%"))
nearest = min(
@@ -167,7 +167,7 @@ def remix_form(
{
"request": request,
"site_title": config.SITE_TITLE,
"job_id": job_id,
"job_id": stem,
"options": options,
"opacity_choices": config.OPACITY_CHOICES,
"prefill": prefill,
@@ -186,13 +186,18 @@ def remix_submit(
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)
stem = _job_exists(job_id)
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"])
entry = remix.create_remix_variant(stem, choice)
logger.info("[%s] remix created variant %s", stem, 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)
logger.warning("[%s] remix failed: %s", stem, exc)
return RedirectResponse(url=f"/jobs/{stem}/remix?error={quote(str(exc))}", status_code=303)
return RedirectResponse(url=f"/jobs/{stem}", status_code=303)
+166 -123
View File
@@ -207,20 +207,20 @@ def run_rembg(input_image: Path, output_image: Path) -> tuple[bool, str]:
def preprocess_original(paths: JobPaths) -> JobPaths:
"""Downscale to MAX_EDGE_PX and normalize to sRGB JPEG as `original.jpg`.
"""Downscale to MAX_EDGE_PX and normalize to sRGB JPEG as `{stem}_original.jpg`.
Replaces any non-jpg original in the job dir. Side-effect free for the
incoming copy — only mutates jobs/<id>/.
Side-effect free for the incoming copy — only mutates variants/.
"""
src = paths.original
if not src.exists():
raise PipelineError(f"Original fehlt: {src}")
dest = paths.root / "original.jpg"
dest = paths.variants / f"{paths.stem}_original.jpg"
paths.variants.mkdir(parents=True, exist_ok=True)
# Write to a temp name first so we can replace an existing file in-place
# without reading/writing the same path.
tmp = paths.variants / f".{paths.stem}_pre_{uuid.uuid4().hex[:8]}.jpg"
resize = f"{config.MAX_EDGE_PX}x{config.MAX_EDGE_PX}>"
# Write to a temp name first so we can replace an existing original.jpg
# in-place without reading/writing the same path.
tmp = paths.root / f".original_pre_{uuid.uuid4().hex[:8]}.jpg"
cmd = _nice(
[
_magick_bin(),
@@ -257,13 +257,12 @@ def preprocess_original(paths: JobPaths) -> JobPaths:
config.MAX_EDGE_PX,
)
return JobPaths(
root=paths.root,
stem=paths.stem,
original=dest,
rembg=paths.rembg,
intermediates=paths.intermediates,
variants=paths.variants,
manifest=paths.manifest,
status=paths.status,
meta=paths.meta,
)
@@ -301,81 +300,11 @@ 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}"
_UNSAFE_STEM_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
def sanitize_stem(name: str) -> str:
"""Safe basename stem for variant files (no path separators / junk)."""
"""Safe basename stem for flat files (no path separators / junk)."""
stem = Path(name).stem if name else ""
stem = stem.replace("/", "_").replace("\\", "_").strip().strip(".")
stem = _UNSAFE_STEM_RE.sub("_", stem).strip("._")
@@ -386,13 +315,134 @@ def variant_filename(stem: str, variant_id: str) -> str:
return f"{sanitize_stem(stem)}_{variant_id}.png"
@dataclass
class JobPaths:
"""Flat layout under DATA_DIR — one stem, shared variants/ + intermediates/.
variants/{stem}_original.jpg
variants/{stem}_rembg.png
variants/{stem}_v1.png
intermediates/{stem}_v1_*.png
meta/{stem}.json
"""
stem: str
original: Path
rembg: Path
intermediates: Path
variants: Path
meta: Path
def job_paths(stem: str, original_suffix: str = ".jpg") -> JobPaths:
stem = sanitize_stem(stem)
return JobPaths(
stem=stem,
original=config.VARIANTS_DIR / f"{stem}_original{original_suffix}",
rembg=config.VARIANTS_DIR / f"{stem}_rembg.png",
intermediates=config.INTERMEDIATES_DIR,
variants=config.VARIANTS_DIR,
meta=config.META_DIR / f"{stem}.json",
)
def _read_meta(stem: str) -> dict[str, Any] | None:
paths = job_paths(stem)
if not paths.meta.exists():
return None
try:
return json.loads(paths.meta.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _write_meta(paths: JobPaths, data: dict[str, Any]) -> None:
paths.meta.parent.mkdir(parents=True, exist_ok=True)
data["updated_at"] = now_iso()
tmp = paths.meta.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(paths.meta)
def read_status(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id)
if not data:
return None
return {
"status": data.get("status", "unknown"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"error": data.get("error"),
"variant_errors": data.get("variant_errors"),
"source_file": data.get("source_file"),
"source_stem": data.get("source_stem"),
}
def write_status(paths: JobPaths, status: str, **extra: Any) -> None:
data = _read_meta(paths.stem) or {}
data["job_id"] = paths.stem
data["source_stem"] = paths.stem
data["status"] = status
data.setdefault("created_at", now_iso())
data.update(extra)
_write_meta(paths, data)
def read_manifest(job_id: str) -> dict[str, Any] | None:
data = _read_meta(job_id)
if not data:
return None
return {
"job_id": data.get("job_id", job_id),
"original_file": data.get("original_file"),
"rembg_file": data.get("rembg_file"),
"source_stem": data.get("source_stem", job_id),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"variants": data.get("variants") or [],
}
def write_manifest(paths: JobPaths, manifest: dict[str, Any]) -> None:
data = _read_meta(paths.stem) or {}
data["job_id"] = paths.stem
data["source_stem"] = manifest.get("source_stem") or paths.stem
data["original_file"] = manifest.get("original_file")
data["rembg_file"] = manifest.get("rembg_file")
data["variants"] = manifest.get("variants") or []
if "created_at" in manifest:
data.setdefault("created_at", manifest["created_at"])
data.setdefault("status", data.get("status", "processing"))
_write_meta(paths, data)
def list_job_ids() -> list[str]:
if not config.META_DIR.exists():
return []
ids = []
for path in config.META_DIR.glob("*.json"):
if path.name.endswith(".json.tmp"):
continue
ids.append(path.stem)
return ids
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 resolve_source_stem(manifest: dict[str, Any] | None, paths: JobPaths) -> str:
"""Prefer manifest source_stem; fall back to original path stem."""
"""Prefer manifest source_stem; fall back to JobPaths.stem."""
if manifest:
stored = manifest.get("source_stem")
if isinstance(stored, str) and stored.strip():
return sanitize_stem(stored)
return sanitize_stem(paths.original.stem)
return paths.stem
def compose_variant(
@@ -401,7 +451,7 @@ def compose_variant(
*,
variant_id: str,
source: str,
variant_stem: str,
variant_stem: str | None = None,
bg_name: str | None = None,
bg_mode: str | None = None,
fg_name: str | None = None,
@@ -411,16 +461,15 @@ def compose_variant(
) -> 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().
Final file is `variants/{stem}_{variant_id}.png`; manifest id stays `vN`.
Final file is `variants/{stem}_{variant_id}.png`; intermediates share
the same stem prefix under `intermediates/`.
"""
rng = rng or random.Random()
tmp_dir = paths.intermediates
tmp_dir.mkdir(parents=True, exist_ok=True)
stem = sanitize_stem(variant_stem)
paths.variants.mkdir(parents=True, exist_ok=True)
stem = sanitize_stem(variant_stem or paths.stem)
prefix = f"{stem}_{variant_id}"
bg_mode = bg_mode or rng.choice(assets.blend_modes)
fg_mode = fg_mode or rng.choice(assets.blend_modes)
@@ -444,14 +493,13 @@ def compose_variant(
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",
"bg_filtered": tmp_dir / f"{prefix}_bg_filtered.png",
"step1": tmp_dir / f"{prefix}_bg_blend.png",
"step2": tmp_dir / f"{prefix}_rembg_alpha.png",
"fg_filtered": tmp_dir / f"{prefix}_fg_filtered.png",
"composed": tmp_dir / f"{prefix}_composed.png",
"final": paths.variants / variant_filename(stem, variant_id),
}
paths.variants.mkdir(parents=True, exist_ok=True)
ok, err = apply_filter(paths.original, bg_command, p["bg_filtered"])
_raise_on_gmic_failure(ok, err, "Background-Filter fehlgeschlagen")
@@ -468,14 +516,13 @@ def compose_variant(
ok, err = blend_layers_opacity(p["step2"], p["fg_filtered"], fg_mode, opacity, p["composed"])
_raise_on_gmic_failure(ok, err, "Foreground-Blend fehlgeschlagen")
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, variant_id)
ok, err = apply_filter_chain(p["composed"], config.POST_FILTERS, p["final"], tmp_dir, prefix)
_raise_on_gmic_failure(ok, err, "Post-Processing fehlgeschlagen")
rel = lambda path: str(path.relative_to(paths.root))
return {
"id": variant_id,
"source": source,
"file": rel(p["final"]),
"file": p["final"].name,
"background_filter": bg_name,
"background_command": bg_command,
"background_blend": bg_mode,
@@ -486,11 +533,11 @@ def compose_variant(
"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"]),
"bg_filtered": p["bg_filtered"].name,
"bg_blend": p["step1"].name,
"rembg_alpha": p["step2"].name,
"fg_filtered": p["fg_filtered"].name,
"composed": p["composed"].name,
},
}
@@ -503,21 +550,16 @@ def process_job(
source_stem: str | None = None,
) -> None:
"""Full pipeline for a freshly ingested incoming file: preprocess, rembg
once, generate OUTPUT_COUNT variants, write manifest + status.
once, generate OUTPUT_COUNT variants, write meta (status + manifest).
`source_path` must already be a private copy (jobs/<id>/original.*) —
callers (worker.py) are responsible for copying out of incoming first, so
the incoming file itself is never touched here.
Variants that hit FILTER_TIMEOUT during the normal pass are retried
once at the end with FILTER_TIMEOUT_LONG. Filter probes in
pick_working_filter stay on the short timeout (skip, don't escalate).
`job_id` is the sanitized source stem. `source_path` must already be the
private copy at variants/{stem}_original{suffix}.
"""
paths = job_paths(job_id, original_suffix)
paths.root.mkdir(parents=True, exist_ok=True)
paths.intermediates.mkdir(parents=True, exist_ok=True)
stem = sanitize_stem(source_stem or job_id)
paths = job_paths(stem, original_suffix)
paths.variants.mkdir(parents=True, exist_ok=True)
stem = sanitize_stem(source_stem or source_path.stem)
paths.intermediates.mkdir(parents=True, exist_ok=True)
paths.meta.parent.mkdir(parents=True, exist_ok=True)
write_status(paths, "processing", source_file=str(source_path.name), source_stem=stem)
@@ -525,13 +567,14 @@ def process_job(
paths = preprocess_original(paths)
manifest: dict[str, Any] = {
"job_id": job_id,
"job_id": stem,
"original_file": paths.original.name,
"rembg_file": paths.rembg.name,
"source_stem": stem,
"created_at": now_iso(),
"variants": [],
}
write_manifest(paths, manifest)
ok, err = run_rembg(paths.original, paths.rembg)
if not ok:
@@ -556,16 +599,16 @@ def process_job(
manifest["variants"].append(entry)
write_manifest(paths, manifest)
except FilterTimeoutError as exc:
logger.warning("[%s] variant %s timed out (will retry long): %s", job_id, variant_id, exc)
logger.warning("[%s] variant %s timed out (will retry long): %s", stem, variant_id, exc)
timeout_retries.append(variant_id)
except PipelineError as exc:
logger.error("[%s] variant %s failed: %s", job_id, variant_id, exc)
logger.error("[%s] variant %s failed: %s", stem, variant_id, exc)
variant_errors.append(f"{variant_id}: {exc}")
if timeout_retries:
logger.info(
"[%s] long-retry %d variant(s) with timeout=%ds: %s",
job_id,
stem,
len(timeout_retries),
config.FILTER_TIMEOUT_LONG,
", ".join(timeout_retries),
@@ -584,7 +627,7 @@ def process_job(
manifest["variants"].append(entry)
write_manifest(paths, manifest)
except PipelineError as exc:
logger.error("[%s] variant %s long-retry failed: %s", job_id, variant_id, exc)
logger.error("[%s] variant %s long-retry failed: %s", stem, variant_id, exc)
variant_errors.append(f"{variant_id}: {exc}")
if not manifest["variants"]:
@@ -592,8 +635,8 @@ def process_job(
write_status(paths, "done", variant_errors=variant_errors)
except PipelineError as exc:
logger.error("[%s] job failed: %s", job_id, exc)
logger.error("[%s] job failed: %s", stem, 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)
logger.exception("[%s] unexpected error", stem)
write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}")
+7 -5
View File
@@ -26,11 +26,12 @@ def build_remix_options(assets: FilterAssets) -> dict[str, list[str]]:
}
def create_remix_variant(job_id: str, original_suffix: str, choice: RemixChoice) -> dict:
def create_remix_variant(job_id: 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
the job's meta. 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)
stem = pipeline.sanitize_stem(job_id)
paths: JobPaths = pipeline.job_paths(stem, ".jpg")
if not paths.original.exists() or not paths.rembg.exists():
raise PipelineError("Original oder Rembg-Bild fehlt fuer diesen Job — Remix nicht moeglich.")
@@ -42,10 +43,11 @@ def create_remix_variant(job_id: str, original_suffix: str, choice: RemixChoice)
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,
manifest = pipeline.read_manifest(stem) or {
"job_id": stem,
"original_file": paths.original.name,
"rembg_file": paths.rembg.name,
"source_stem": stem,
"created_at": pipeline.now_iso(),
"variants": [],
}
+4 -4
View File
@@ -21,12 +21,12 @@
</figcaption>
</figure>
{% endif %}
{% if has_rembg %}
{% if has_rembg and rembg_name %}
<figure>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/rembg.png" alt="Freigestellt (rembg)" loading="lazy">
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ rembg_name }}" alt="Freigestellt (rembg)" loading="lazy">
<figcaption>
Rembg &middot;
<a href="/jobs/{{ job_id }}/files/rembg.png" download>Download</a>
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
</figcaption>
</figure>
{% endif %}
@@ -73,7 +73,7 @@
<ul class="intermediate-grid">
{% for name in intermediates %}
<li>
<img class="lightboxable" src="/jobs/{{ job_id }}/files/intermediates/{{ name }}" alt="{{ name }}" loading="lazy">
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ name }}" alt="{{ name }}" loading="lazy">
<span>{{ name }}</span>
</li>
{% endfor %}
+18 -14
View File
@@ -1,10 +1,10 @@
"""Incoming watcher + sequential job runner.
Polls `DATA_DIR/incoming` 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 incoming — `processed.json`
tracks what has already been handled (by path + size + mtime) so restarts
don't reprocess everything.
into `variants/{stem}_original.*` and runs the compose pipeline on it.
Files are NEVER deleted or moved from incoming — `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
@@ -30,7 +30,12 @@ logger = logging.getLogger("livef12.worker")
def ensure_dirs() -> None:
for path in (config.INBOX_DIR, config.JOBS_DIR):
for path in (
config.INBOX_DIR,
config.VARIANTS_DIR,
config.INTERMEDIATES_DIR,
config.META_DIR,
):
path.mkdir(parents=True, exist_ok=True)
@@ -86,7 +91,7 @@ def _is_stable(path: Path) -> bool:
def find_new_files(processed: dict[str, Any]) -> list[Path]:
"""Top-level images under incoming/ only — never recurse into jobs/."""
"""Top-level images under incoming/ only — never recurse."""
if not config.INBOX_DIR.exists():
return []
candidates: list[Path] = []
@@ -113,26 +118,25 @@ def handle_file(path: Path, processed: dict[str, Any]) -> None:
return
stat = path.stat()
job_id = pipeline.new_job_id()
stem = pipeline.sanitize_stem(path.stem)
suffix = path.suffix.lower()
paths = pipeline.job_paths(job_id, suffix)
paths.root.mkdir(parents=True, exist_ok=True)
source_stem = pipeline.sanitize_stem(path.stem)
paths = pipeline.job_paths(stem, suffix)
paths.variants.mkdir(parents=True, exist_ok=True)
logger.info("new incoming file %s -> job %s (stem=%s)", path.name, job_id, source_stem)
logger.info("new incoming file %s -> stem %s", path.name, stem)
# Copy (never move) so incoming stays untouched.
shutil.copy2(path, paths.original)
try:
pipeline.process_job(job_id, paths.original, suffix, source_stem=source_stem)
pipeline.process_job(stem, paths.original, suffix, source_stem=stem)
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.
# meta/{stem}.json for manual follow-up.
processed[_file_key(path)] = {
"size": stat.st_size,
"mtime": stat.st_mtime,
"job_id": job_id,
"job_id": stem,
"processed_at": pipeline.now_iso(),
}
save_processed(processed)