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:
+58
-53
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user