app.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  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 os
  14. import subprocess
  15. import sys
  16. import time
  17. import uuid
  18. from pathlib import Path
  19. from fastapi import FastAPI, UploadFile, File, HTTPException
  20. from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
  21. from fastapi.staticfiles import StaticFiles
  22. import config
  23. import pipeline
  24. app = FastAPI(title="GenRecon", version="1.0")
  25. RUN_JOB = str(Path(__file__).resolve().parent / "run_job.py")
  26. def _job_dir(job_id: str) -> Path:
  27. return config.JOBS_DIR / job_id
  28. def _status_path(job_id: str) -> Path:
  29. return _job_dir(job_id) / "status.json"
  30. def _read(job_id: str) -> dict | None:
  31. p = _status_path(job_id)
  32. if not p.exists():
  33. return None
  34. try:
  35. return json.loads(p.read_text())
  36. except Exception:
  37. return None
  38. def _write(job_id: str, **patch) -> dict:
  39. st = _read(job_id) or {"id": job_id}
  40. st.update(patch)
  41. st["updated"] = time.time()
  42. _status_path(job_id).write_text(json.dumps(st))
  43. return st
  44. def _log_line(job_id: str, line: str) -> None:
  45. with open(_job_dir(job_id) / "pipeline.log", "a") as f:
  46. f.write(line + "\n")
  47. def _pid_alive(pid: int) -> bool:
  48. try:
  49. os.kill(pid, 0)
  50. return True
  51. except (OSError, ValueError):
  52. return False
  53. def _runner_alive(job_id: str) -> bool:
  54. try:
  55. return _pid_alive(int((_job_dir(job_id) / "worker.pid").read_text().strip()))
  56. except Exception:
  57. return False
  58. def _launch_runner(job_id: str) -> None:
  59. """Run the reconstruction in a DETACHED process that outlives this web service
  60. (KillMode=process on the unit), self-serialized on the GPU by an flock. A
  61. redeploy / restart of genrecon-app therefore never kills an in-flight scan."""
  62. subprocess.Popen(
  63. [sys.executable, RUN_JOB, job_id],
  64. cwd=str(Path(RUN_JOB).parent),
  65. stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
  66. start_new_session=True, close_fds=True,
  67. )
  68. @app.on_event("startup")
  69. def _startup() -> None:
  70. # Re-attach: any job that isn't finished gets a live runner. If its detached
  71. # runner survived our restart (KillMode=process) we leave it alone; otherwise
  72. # we (re)launch — so a service restart never strands a reconstruction.
  73. for d in sorted(config.JOBS_DIR.glob("*/")):
  74. st = _read(d.name)
  75. if not st or st.get("status") in ("done", "error"):
  76. continue
  77. if not _runner_alive(d.name):
  78. _launch_runner(d.name)
  79. @app.get("/api/health")
  80. def health() -> dict:
  81. active = sum(1 for d in config.JOBS_DIR.glob("*/")
  82. if (_read(d.name) or {}).get("status") in ("queued", "running"))
  83. return {"ok": True, "mock": config.MOCK, "active": active}
  84. @app.post("/api/jobs")
  85. async def create_job(video: UploadFile = File(...)) -> dict:
  86. job_id = uuid.uuid4().hex[:12]
  87. d = _job_dir(job_id)
  88. d.mkdir(parents=True, exist_ok=True)
  89. ext = (Path(video.filename or "").suffix or ".mp4").lower()
  90. dest = d / f"input{ext}"
  91. size = 0
  92. cap = config.MAX_UPLOAD_MB * 1024 * 1024
  93. with open(dest, "wb") as f:
  94. while chunk := await video.read(1 << 20):
  95. size += len(chunk)
  96. if size > cap:
  97. f.close()
  98. import shutil
  99. shutil.rmtree(d, ignore_errors=True)
  100. raise HTTPException(413, f"upload exceeds {config.MAX_UPLOAD_MB} MB")
  101. f.write(chunk)
  102. _write(job_id, status="queued", stage=None, progress=0, message="queued",
  103. created=time.time(), filename=video.filename, error=None)
  104. _launch_runner(job_id)
  105. return {"id": job_id, "status": "queued"}
  106. @app.get("/api/jobs/{job_id}")
  107. def get_job(job_id: str) -> dict:
  108. st = _read(job_id)
  109. if not st:
  110. raise HTTPException(404, "no such job")
  111. return st
  112. @app.get("/api/jobs/{job_id}/scene.glb")
  113. def get_scene(job_id: str):
  114. p = _job_dir(job_id) / "out" / "scene.glb"
  115. if not p.exists():
  116. raise HTTPException(404, "scene not ready")
  117. return FileResponse(p, media_type="model/gltf-binary", filename="scene.glb")
  118. @app.get("/api/jobs/{job_id}/log")
  119. def get_log(job_id: str):
  120. p = _job_dir(job_id) / "pipeline.log"
  121. return PlainTextResponse(p.read_text() if p.exists() else "")
  122. # The PWA. Mounted last so /api/* wins. html=True serves index.html at /.
  123. if config.FRONTEND_DIR.exists():
  124. app.mount("/", StaticFiles(directory=str(config.FRONTEND_DIR), html=True), name="frontend")
  125. else: # pragma: no cover
  126. @app.get("/")
  127. def _no_frontend() -> JSONResponse:
  128. return JSONResponse({"error": "frontend not found", "expected": str(config.FRONTEND_DIR)}, 500)