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
+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)