mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-25 09:34:21 +08:00
backlot phase 1: board server (state derivation, watcher, SSE, media, CLI)
- backlot/state.py: BoardState from disk — stage rail with gate audit
(gate_skipped detection from history/), scene_plan x script x
asset_manifest storyboard join, takes, generating-state from events,
media discovery incl. atelier root-render heuristic, degradation ladder,
library summaries
- backlot/server.py: FastAPI on 4750 — /api/projects, /api/project/{id}/state,
SSE change feeds (project + library) fed by a watchfiles watcher,
/media with range support and traversal protection, UI mounts
- backlot/__main__.py: 'python -m backlot open [project]' idempotent
launcher (spawns detached server, opens browser); 'serve' foreground
- verified against real projects: 73 listed, full state for
signal-from-tomorrow, 206 range responses, SSE change push on
filesystem write
This commit is contained in:
217
backlot/server.py
Normal file
217
backlot/server.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Backlot server — FastAPI app: board state API, SSE change feed, media.
|
||||
|
||||
The watcher observes ``projects/`` with watchfiles; on any change it bumps a
|
||||
per-project version and wakes SSE subscribers, who tell the browser to
|
||||
refetch state. The server never writes to project directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from backlot.state import PROJECTS_DIR, REPO_ROOT, list_projects, load_board_state
|
||||
|
||||
UI_DIR = Path(__file__).resolve().parent / "ui"
|
||||
|
||||
# Paths inside a project whose changes are pure noise for the board.
|
||||
_IGNORE_PARTS = {"node_modules", ".git", "__pycache__", ".cache"}
|
||||
|
||||
SSE_HEARTBEAT_SECONDS = 15
|
||||
|
||||
|
||||
class ChangeHub:
|
||||
"""Fan-out of project-change notifications to SSE subscribers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
self._subscribers.add(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: asyncio.Queue) -> None:
|
||||
self._subscribers.discard(q)
|
||||
|
||||
def publish(self, project_id: str) -> None:
|
||||
for q in list(self._subscribers):
|
||||
try:
|
||||
q.put_nowait(project_id)
|
||||
except asyncio.QueueFull:
|
||||
pass # subscriber is behind; it will refetch on next event
|
||||
|
||||
|
||||
hub = ChangeHub()
|
||||
|
||||
|
||||
def _project_of_change(path_str: str) -> Optional[str]:
|
||||
"""Map a changed filesystem path to a project id (None = irrelevant)."""
|
||||
try:
|
||||
rel = Path(path_str).resolve().relative_to(PROJECTS_DIR.resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if not rel.parts:
|
||||
return None
|
||||
if _IGNORE_PARTS.intersection(rel.parts):
|
||||
return None
|
||||
return rel.parts[0]
|
||||
|
||||
|
||||
async def _watch_projects() -> None:
|
||||
"""Background task: watch projects/ and publish debounced changes."""
|
||||
try:
|
||||
from watchfiles import awatch
|
||||
except ImportError:
|
||||
return # watcher unavailable → board still works via manual refresh
|
||||
if not PROJECTS_DIR.is_dir():
|
||||
return
|
||||
async for changes in awatch(PROJECTS_DIR, recursive=True, step=400):
|
||||
touched: set[str] = set()
|
||||
for _change, path_str in changes:
|
||||
pid = _project_of_change(path_str)
|
||||
if pid:
|
||||
touched.add(pid)
|
||||
for pid in touched:
|
||||
hub.publish(pid)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Backlot", docs_url=None, redoc_url=None)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup() -> None:
|
||||
app.state.watch_task = asyncio.create_task(_watch_projects())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown() -> None:
|
||||
task = getattr(app.state, "watch_task", None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
# ---- API ----------------------------------------------------------
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {"ok": True, "app": "backlot"}
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def projects() -> list:
|
||||
return await asyncio.to_thread(list_projects)
|
||||
|
||||
@app.get("/api/project/{project_id}/state")
|
||||
async def project_state(project_id: str) -> dict:
|
||||
project_dir = _safe_project_dir(project_id)
|
||||
return await asyncio.to_thread(load_board_state, project_dir)
|
||||
|
||||
@app.get("/api/project/{project_id}/events")
|
||||
async def project_events(project_id: str, request: Request) -> StreamingResponse:
|
||||
_safe_project_dir(project_id) # 404 early for unknown projects
|
||||
|
||||
async def stream():
|
||||
q = hub.subscribe()
|
||||
try:
|
||||
yield _sse({"type": "hello", "project_id": project_id})
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
try:
|
||||
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
yield _sse({"type": "heartbeat", "ts": time.time()})
|
||||
continue
|
||||
if changed == project_id:
|
||||
# Coalesce bursts: drain anything queued for this project.
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
yield _sse({"type": "change", "project_id": project_id})
|
||||
finally:
|
||||
hub.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
})
|
||||
|
||||
@app.get("/api/library/events")
|
||||
async def library_events(request: Request) -> StreamingResponse:
|
||||
async def stream():
|
||||
q = hub.subscribe()
|
||||
try:
|
||||
yield _sse({"type": "hello"})
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
try:
|
||||
changed = await asyncio.wait_for(q.get(), timeout=SSE_HEARTBEAT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
yield _sse({"type": "heartbeat", "ts": time.time()})
|
||||
continue
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
yield _sse({"type": "change", "project_id": changed})
|
||||
finally:
|
||||
hub.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream", headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
})
|
||||
|
||||
# ---- Media (range requests handled by FileResponse) ---------------
|
||||
|
||||
@app.get("/media/{project_id}/{file_path:path}")
|
||||
async def media(project_id: str, file_path: str) -> FileResponse:
|
||||
project_dir = _safe_project_dir(project_id)
|
||||
target = (project_dir / file_path).resolve()
|
||||
try:
|
||||
target.relative_to(project_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="path escapes project")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="media not found")
|
||||
return FileResponse(target)
|
||||
|
||||
# ---- UI ------------------------------------------------------------
|
||||
|
||||
@app.get("/p/{project_id}")
|
||||
async def board_page(project_id: str) -> FileResponse:
|
||||
return FileResponse(UI_DIR / "board.html")
|
||||
|
||||
@app.get("/")
|
||||
async def library_page() -> FileResponse:
|
||||
return FileResponse(UI_DIR / "index.html")
|
||||
|
||||
if UI_DIR.is_dir():
|
||||
app.mount("/ui", StaticFiles(directory=UI_DIR), name="ui")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _safe_project_dir(project_id: str) -> Path:
|
||||
if "/" in project_id or "\\" in project_id or project_id in (".", ".."):
|
||||
raise HTTPException(status_code=400, detail="invalid project id")
|
||||
project_dir = PROJECTS_DIR / project_id
|
||||
if not project_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"unknown project: {project_id}")
|
||||
return project_dir
|
||||
|
||||
|
||||
def _sse(payload: dict) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
app = create_app()
|
||||
Reference in New Issue
Block a user