| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- """GenRecon web service — FastAPI.
- POST /api/jobs multipart `video` -> {id}
- GET /api/jobs/{id} status json
- GET /api/jobs/{id}/scene.glb the reconstructed scene
- GET /api/jobs/{id}/log raw pipeline log (debug)
- GET /api/health
- / the PWA (static)
- One serial worker thread (single GPU). Job state persists to
- <JOBS_DIR>/<id>/status.json so it survives a restart.
- """
- from __future__ import annotations
- import json
- import queue
- import threading
- import time
- import uuid
- from pathlib import Path
- from fastapi import FastAPI, UploadFile, File, HTTPException
- from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
- from fastapi.staticfiles import StaticFiles
- import config
- import pipeline
- app = FastAPI(title="GenRecon", version="1.0")
- _work: "queue.Queue[str]" = queue.Queue()
- def _job_dir(job_id: str) -> Path:
- return config.JOBS_DIR / job_id
- def _status_path(job_id: str) -> Path:
- return _job_dir(job_id) / "status.json"
- def _read(job_id: str) -> dict | None:
- p = _status_path(job_id)
- if not p.exists():
- return None
- try:
- return json.loads(p.read_text())
- except Exception:
- return None
- def _write(job_id: str, **patch) -> dict:
- st = _read(job_id) or {"id": job_id}
- st.update(patch)
- st["updated"] = time.time()
- _status_path(job_id).write_text(json.dumps(st))
- return st
- def _log_line(job_id: str, line: str) -> None:
- with open(_job_dir(job_id) / "pipeline.log", "a") as f:
- f.write(line + "\n")
- def _worker() -> None:
- while True:
- job_id = _work.get()
- try:
- _write(job_id, status="running", stage=config.STAGES[0][0], progress=1, message="start", error=None)
- pipeline.run_pipeline(
- _job_dir(job_id),
- log=lambda ln, j=job_id: _log_line(j, ln),
- set_progress=lambda stage, pct, msg, j=job_id: _write(j, status="running", stage=stage, progress=pct, message=msg),
- )
- _write(job_id, status="done", stage="glb", progress=100, message="ready")
- except Exception as e: # noqa: BLE001 — surface any stage failure to the client
- _log_line(job_id, f"ERROR: {e}")
- _write(job_id, status="error", message="failed", error=str(e))
- finally:
- _work.task_done()
- @app.on_event("startup")
- def _startup() -> None:
- # Re-queue jobs that were queued, fail jobs that were mid-flight at shutdown.
- for d in sorted(config.JOBS_DIR.glob("*/")):
- st = _read(d.name)
- if not st:
- continue
- if st.get("status") == "queued":
- _work.put(d.name)
- elif st.get("status") == "running":
- _write(d.name, status="error", message="interrupted by restart",
- error="server restarted while this job was running")
- threading.Thread(target=_worker, daemon=True).start()
- @app.get("/api/health")
- def health() -> dict:
- return {"ok": True, "mock": config.MOCK, "queue": _work.qsize()}
- @app.post("/api/jobs")
- async def create_job(video: UploadFile = File(...)) -> dict:
- job_id = uuid.uuid4().hex[:12]
- d = _job_dir(job_id)
- d.mkdir(parents=True, exist_ok=True)
- ext = (Path(video.filename or "").suffix or ".mp4").lower()
- dest = d / f"input{ext}"
- size = 0
- cap = config.MAX_UPLOAD_MB * 1024 * 1024
- with open(dest, "wb") as f:
- while chunk := await video.read(1 << 20):
- size += len(chunk)
- if size > cap:
- f.close()
- import shutil
- shutil.rmtree(d, ignore_errors=True)
- raise HTTPException(413, f"upload exceeds {config.MAX_UPLOAD_MB} MB")
- f.write(chunk)
- _write(job_id, status="queued", stage=None, progress=0, message="queued",
- created=time.time(), filename=video.filename, error=None)
- _work.put(job_id)
- return {"id": job_id, "status": "queued"}
- @app.get("/api/jobs/{job_id}")
- def get_job(job_id: str) -> dict:
- st = _read(job_id)
- if not st:
- raise HTTPException(404, "no such job")
- return st
- @app.get("/api/jobs/{job_id}/scene.glb")
- def get_scene(job_id: str):
- p = _job_dir(job_id) / "out" / "scene.glb"
- if not p.exists():
- raise HTTPException(404, "scene not ready")
- return FileResponse(p, media_type="model/gltf-binary", filename="scene.glb")
- @app.get("/api/jobs/{job_id}/log")
- def get_log(job_id: str):
- p = _job_dir(job_id) / "pipeline.log"
- return PlainTextResponse(p.read_text() if p.exists() else "")
- # The PWA. Mounted last so /api/* wins. html=True serves index.html at /.
- if config.FRONTEND_DIR.exists():
- app.mount("/", StaticFiles(directory=str(config.FRONTEND_DIR), html=True), name="frontend")
- else: # pragma: no cover
- @app.get("/")
- def _no_frontend() -> JSONResponse:
- return JSONResponse({"error": "frontend not found", "expected": str(config.FRONTEND_DIR)}, 500)
|