app.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """GenRecon web service — FastAPI.
  2. POST /api/jobs multipart `video` -> {id}
  3. GET /api/jobs/{id} status json
  4. GET /api/jobs/{id}/scene.glb the reconstructed scene
  5. GET /api/jobs/{id}/log raw pipeline log (debug)
  6. GET /api/health
  7. / the PWA (static)
  8. One serial worker thread (single GPU). Job state persists to
  9. <JOBS_DIR>/<id>/status.json so it survives a restart.
  10. """
  11. from __future__ import annotations
  12. import json
  13. import queue
  14. import threading
  15. import time
  16. import uuid
  17. from pathlib import Path
  18. from fastapi import FastAPI, UploadFile, File, HTTPException
  19. from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
  20. from fastapi.staticfiles import StaticFiles
  21. import config
  22. import pipeline
  23. app = FastAPI(title="GenRecon", version="1.0")
  24. _work: "queue.Queue[str]" = queue.Queue()
  25. def _job_dir(job_id: str) -> Path:
  26. return config.JOBS_DIR / job_id
  27. def _status_path(job_id: str) -> Path:
  28. return _job_dir(job_id) / "status.json"
  29. def _read(job_id: str) -> dict | None:
  30. p = _status_path(job_id)
  31. if not p.exists():
  32. return None
  33. try:
  34. return json.loads(p.read_text())
  35. except Exception:
  36. return None
  37. def _write(job_id: str, **patch) -> dict:
  38. st = _read(job_id) or {"id": job_id}
  39. st.update(patch)
  40. st["updated"] = time.time()
  41. _status_path(job_id).write_text(json.dumps(st))
  42. return st
  43. def _log_line(job_id: str, line: str) -> None:
  44. with open(_job_dir(job_id) / "pipeline.log", "a") as f:
  45. f.write(line + "\n")
  46. def _worker() -> None:
  47. while True:
  48. job_id = _work.get()
  49. try:
  50. _write(job_id, status="running", stage=config.STAGES[0][0], progress=1, message="start", error=None)
  51. pipeline.run_pipeline(
  52. _job_dir(job_id),
  53. log=lambda ln, j=job_id: _log_line(j, ln),
  54. set_progress=lambda stage, pct, msg, j=job_id: _write(j, status="running", stage=stage, progress=pct, message=msg),
  55. )
  56. _write(job_id, status="done", stage="glb", progress=100, message="ready")
  57. except Exception as e: # noqa: BLE001 — surface any stage failure to the client
  58. _log_line(job_id, f"ERROR: {e}")
  59. _write(job_id, status="error", message="failed", error=str(e))
  60. finally:
  61. _work.task_done()
  62. @app.on_event("startup")
  63. def _startup() -> None:
  64. # Re-queue jobs that were queued, fail jobs that were mid-flight at shutdown.
  65. for d in sorted(config.JOBS_DIR.glob("*/")):
  66. st = _read(d.name)
  67. if not st:
  68. continue
  69. if st.get("status") == "queued":
  70. _work.put(d.name)
  71. elif st.get("status") == "running":
  72. _write(d.name, status="error", message="interrupted by restart",
  73. error="server restarted while this job was running")
  74. threading.Thread(target=_worker, daemon=True).start()
  75. @app.get("/api/health")
  76. def health() -> dict:
  77. return {"ok": True, "mock": config.MOCK, "queue": _work.qsize()}
  78. @app.post("/api/jobs")
  79. async def create_job(video: UploadFile = File(...)) -> dict:
  80. job_id = uuid.uuid4().hex[:12]
  81. d = _job_dir(job_id)
  82. d.mkdir(parents=True, exist_ok=True)
  83. ext = (Path(video.filename or "").suffix or ".mp4").lower()
  84. dest = d / f"input{ext}"
  85. size = 0
  86. cap = config.MAX_UPLOAD_MB * 1024 * 1024
  87. with open(dest, "wb") as f:
  88. while chunk := await video.read(1 << 20):
  89. size += len(chunk)
  90. if size > cap:
  91. f.close()
  92. import shutil
  93. shutil.rmtree(d, ignore_errors=True)
  94. raise HTTPException(413, f"upload exceeds {config.MAX_UPLOAD_MB} MB")
  95. f.write(chunk)
  96. _write(job_id, status="queued", stage=None, progress=0, message="queued",
  97. created=time.time(), filename=video.filename, error=None)
  98. _work.put(job_id)
  99. return {"id": job_id, "status": "queued"}
  100. @app.get("/api/jobs/{job_id}")
  101. def get_job(job_id: str) -> dict:
  102. st = _read(job_id)
  103. if not st:
  104. raise HTTPException(404, "no such job")
  105. return st
  106. @app.get("/api/jobs/{job_id}/scene.glb")
  107. def get_scene(job_id: str):
  108. p = _job_dir(job_id) / "out" / "scene.glb"
  109. if not p.exists():
  110. raise HTTPException(404, "scene not ready")
  111. return FileResponse(p, media_type="model/gltf-binary", filename="scene.glb")
  112. @app.get("/api/jobs/{job_id}/log")
  113. def get_log(job_id: str):
  114. p = _job_dir(job_id) / "pipeline.log"
  115. return PlainTextResponse(p.read_text() if p.exists() else "")
  116. # The PWA. Mounted last so /api/* wins. html=True serves index.html at /.
  117. if config.FRONTEND_DIR.exists():
  118. app.mount("/", StaticFiles(directory=str(config.FRONTEND_DIR), html=True), name="frontend")
  119. else: # pragma: no cover
  120. @app.get("/")
  121. def _no_frontend() -> JSONResponse:
  122. return JSONResponse({"error": "frontend not found", "expected": str(config.FRONTEND_DIR)}, 500)