Files
OpenMontage/backlot/server.py
calesthio 4b4d7b1e99 backlot phase 2: board UI (library, live board, filmstrip, script modal, drawers)
- vanilla ESM frontend on the mockup design system (no build step):
  library contact-sheet with mini rails + live badges; project board with
  slate header, cost meter, clickable stage rail, stage drawers (artifact
  view, review findings, gate-skipped chip, versions), screenplay card +
  full-script modal, decisions + activity rails, storyboard filmstrip
  (duration-width cards, shimmer/spec/missing/text-card states, takes,
  narration + waveform playback), render player with versions, degraded
  found-media view
- /thumb endpoint: cached downscaled JPEGs (Pillow) + ffmpeg poster-frame
  extraction for videos — the library was loading 73 full-res PNGs
- library summaries cached, invalidated by the watcher
- asset path resolution tolerates project-relative, repo-relative and
  absolute manifest paths (real-world variance found in why-do-we-dream)
- ?static=1 disables SSE (screenshots/static export)
- verified in browser against signal-from-tomorrow, why-do-we-dream,
  done-beats-perfect and the 73-project library
2026-07-01 23:47:45 -07:00

313 lines
11 KiB
Python

"""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, summarize_project
UI_DIR = Path(__file__).resolve().parent / "ui"
THUMB_CACHE_DIR = REPO_ROOT / ".backlot" / "thumbs"
THUMB_WIDTHS = (320, 640, 960)
# 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()
# Library summaries are expensive to derive (full state parse per project);
# cache per project and invalidate from the watcher.
_summary_cache: dict[str, dict] = {}
def _invalidate_summary(project_id: str) -> None:
_summary_cache.pop(project_id, None)
def _cached_summaries() -> list[dict]:
if not PROJECTS_DIR.is_dir():
return []
summaries = []
for entry in sorted(PROJECTS_DIR.iterdir()):
if not entry.is_dir() or entry.name.startswith(("_", ".")):
continue
cached = _summary_cache.get(entry.name)
if cached is None:
try:
cached = summarize_project(entry)
except Exception:
cached = {
"project_id": entry.name, "title": entry.name,
"pipeline_type": "unknown", "has_pipeline_state": False,
"poster": None, "live": False, "last_activity": 0,
"active_stage": None, "awaiting_human": False,
"stage_states": [], "completed_count": 0,
"render_count": 0, "scene_count": 0, "error": "unreadable",
}
_summary_cache[entry.name] = cached
summaries.append(cached)
summaries.sort(key=lambda s: (not s["live"], -(s["last_activity"] or 0)))
return summaries
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:
_invalidate_summary(pid)
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(_cached_summaries)
@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",
})
# ---- Thumbnails (downscaled, cached on disk) ------------------------
@app.get("/thumb/{project_id}/{file_path:path}")
async def thumb(project_id: str, file_path: str, w: int = 640) -> 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")
width = min(THUMB_WIDTHS, key=lambda x: abs(x - w))
cached = await asyncio.to_thread(_thumbnail_for, target, width)
if cached is None:
return FileResponse(target) # not an image we can thumb — serve as-is
return FileResponse(cached, media_type="image/jpeg")
# ---- 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"
def _thumbnail_for(source: Path, width: int) -> Optional[Path]:
"""Downscale an image (or extract a video poster frame) to a cached JPEG."""
suffix = source.suffix.lower()
is_image = suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}
is_video = suffix in {".mp4", ".webm", ".mov"}
if not (is_image or is_video):
return None
try:
import hashlib
stat = source.stat()
key = hashlib.sha1(
f"{source}|{stat.st_mtime_ns}|{stat.st_size}|{width}".encode()
).hexdigest()[:20]
cached = THUMB_CACHE_DIR / f"{key}.jpg"
if cached.is_file():
return cached
THUMB_CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = cached.with_suffix(".tmp.jpg")
if is_video:
import subprocess
result = subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-ss", "1.5",
"-i", str(source), "-frames:v", "1",
"-vf", f"scale={width}:-2", str(tmp)],
capture_output=True, timeout=30,
)
if result.returncode != 0 or not tmp.is_file():
return None
else:
from PIL import Image
with Image.open(source) as img:
img = img.convert("RGB")
img.thumbnail((width, width * 3))
img.save(tmp, "JPEG", quality=82)
tmp.replace(cached)
return cached
except Exception:
return None
app = create_app()