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:
calesthio
2026-07-01 23:26:30 -07:00
parent 514d0faf37
commit c80ecbcb2d
6 changed files with 1096 additions and 0 deletions

16
backlot/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""Backlot — the living storyboard.
A read-only, disk-derived production board for OpenMontage. A small local web
server watches ``projects/`` and renders each production's pipeline stages,
script, scene plan, generated assets, decisions, cost, and activity — live.
Design contract (see internal/design/LIVING_STORYBOARD.md):
- Observation, not reporting: all state derives from files the pipeline
already writes. Agents never update the UI.
- Never block, never break: malformed or missing state degrades gracefully.
- The agent's only duty: ``python -m backlot open <project>`` at pipeline init.
"""
__version__ = "0.1.0"
DEFAULT_PORT = 4750

109
backlot/__main__.py Normal file
View File

@@ -0,0 +1,109 @@
"""Backlot CLI.
python -m backlot open [project-id] # start server if needed, open browser
python -m backlot serve [--port N] # run the server in the foreground
``open`` is idempotent and non-fatal by design: agents call it at pipeline
initialization and must continue the production even if it fails.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
import urllib.request
import webbrowser
from backlot import DEFAULT_PORT
def _port() -> int:
try:
return int(os.environ.get("BACKLOT_PORT", DEFAULT_PORT))
except ValueError:
return DEFAULT_PORT
def _server_alive(port: int) -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1.5) as resp:
return resp.status == 200
except Exception:
return False
def _spawn_server(port: int) -> None:
"""Start the server as a detached background process."""
cmd = [sys.executable, "-m", "backlot", "serve", "--port", str(port)]
kwargs: dict = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
kwargs["creationflags"] = (
subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
)
else:
kwargs["start_new_session"] = True
subprocess.Popen(cmd, **kwargs)
def cmd_open(project_id: str | None) -> int:
port = _port()
if not _server_alive(port):
try:
_spawn_server(port)
except Exception as exc:
print(f"backlot: could not start server ({exc}) — continuing without the board")
return 1
deadline = time.time() + 15
while time.time() < deadline:
if _server_alive(port):
break
time.sleep(0.4)
else:
print("backlot: server did not come up in time — continuing without the board")
return 1
url = f"http://127.0.0.1:{port}/"
if project_id:
url = f"http://127.0.0.1:{port}/p/{project_id}"
try:
webbrowser.open(url)
except Exception:
pass
print(f"backlot: {url}")
return 0
def cmd_serve(port: int) -> int:
import uvicorn
uvicorn.run("backlot.server:app", host="127.0.0.1", port=port, log_level="warning")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="backlot", description=__doc__)
sub = parser.add_subparsers(dest="command")
p_open = sub.add_parser("open", help="open the board in the browser (starts server if needed)")
p_open.add_argument("project_id", nargs="?", default=None)
p_serve = sub.add_parser("serve", help="run the Backlot server in the foreground")
p_serve.add_argument("--port", type=int, default=_port())
args = parser.parse_args(argv)
if args.command == "open":
return cmd_open(args.project_id)
if args.command == "serve":
return cmd_serve(args.port)
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())

217
backlot/server.py Normal file
View 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()

559
backlot/state.py Normal file
View File

@@ -0,0 +1,559 @@
"""BoardState derivation — turn a project directory into renderable state.
Everything here is read-only and defensive: a malformed JSON file, a missing
artifact, or a half-written checkpoint must degrade the board, never crash it
(design principle: "never block, never break").
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Optional
from lib.events import read_events
REPO_ROOT = Path(__file__).resolve().parent.parent
PROJECTS_DIR = REPO_ROOT / "projects"
MEDIA_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MEDIA_VIDEO_EXT = {".mp4", ".webm", ".mov"}
MEDIA_AUDIO_EXT = {".mp3", ".wav", ".m4a", ".ogg"}
# Directories inside a project we never scan for media (build noise).
SCAN_EXCLUDE = {"node_modules", ".git", "__pycache__", "history", ".cache"}
# Stages every pipeline shares (fallback rail when the manifest is unknown).
FALLBACK_STAGES = [
"research", "proposal", "idea", "script", "scene_plan",
"assets", "edit", "compose", "publish",
]
# How long (seconds) without filesystem activity before a board reads "idle".
LIVE_WINDOW_SECONDS = 5 * 60
def _read_json(path: Path) -> Optional[dict]:
"""Read a JSON file, returning None on any failure."""
try:
with open(path, encoding="utf-8", errors="replace") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError, UnicodeError):
return None
def _rel(project_dir: Path, path: Path) -> str:
"""Project-relative POSIX path for media URLs."""
try:
return path.relative_to(project_dir).as_posix()
except ValueError:
return path.name
# ---------------------------------------------------------------------------
# Pipeline / stages
# ---------------------------------------------------------------------------
def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
"""Stage order + gate flags from the manifest; graceful fallback."""
if pipeline_type and pipeline_type != "unknown":
try:
from lib.pipeline_loader import load_pipeline
manifest = load_pipeline(pipeline_type)
stages = [
{
"name": s["name"],
"gated": bool(s.get("human_approval_default", False)),
}
for s in manifest.get("stages", [])
]
if stages:
return {
"pipeline_type": pipeline_type,
"stages": stages,
"known": True,
}
except Exception:
pass
return {
"pipeline_type": pipeline_type or "unknown",
"stages": [{"name": s, "gated": False} for s in FALLBACK_STAGES],
"known": False,
}
def _resolve_artifact(project_dir: Path, value: Any) -> Optional[dict]:
"""Checkpoint artifacts may be inline dicts or path strings — resolve both."""
if isinstance(value, dict):
return value
if isinstance(value, str) and value:
p = Path(value)
if not p.is_absolute():
p = project_dir / value
return _read_json(p)
return None
def _collect_checkpoints(project_dir: Path) -> dict[str, dict]:
"""Current checkpoint per stage (raw dicts, unvalidated by design)."""
out: dict[str, dict] = {}
for path in sorted(project_dir.glob("checkpoint_*.json")):
stage = path.stem[len("checkpoint_"):]
data = _read_json(path)
if data is not None:
data["_mtime"] = path.stat().st_mtime
out[stage] = data
return out
def _collect_history(project_dir: Path) -> dict[str, list[dict]]:
"""Archived checkpoint versions per stage (oldest first)."""
history_dir = project_dir / "history"
out: dict[str, list[dict]] = {}
if not history_dir.is_dir():
return out
for path in sorted(history_dir.glob("checkpoint_*.json")):
m = re.match(r"checkpoint_(.+?)_\d", path.stem)
stage = m.group(1) if m else path.stem[len("checkpoint_"):]
data = _read_json(path)
if data is not None:
out.setdefault(stage, []).append(data)
return out
def _build_stage_rail(
pipeline_meta: dict,
checkpoints: dict[str, dict],
history: dict[str, list[dict]],
) -> list[dict]:
"""One entry per manifest stage with derived status + gate audit."""
rail = []
manifest_stage_names = {s["name"] for s in pipeline_meta["stages"]}
for stage_def in pipeline_meta["stages"]:
name = stage_def["name"]
cp = checkpoints.get(name)
versions = history.get(name, [])
status = cp.get("status") if cp else "pending"
entry: dict[str, Any] = {
"name": name,
"gated": stage_def["gated"],
"status": status or "pending",
"timestamp": cp.get("timestamp") if cp else None,
"review": cp.get("review") if cp else None,
"cost_snapshot": cp.get("cost_snapshot") if cp else None,
"error": cp.get("error") if cp else None,
"human_approved": cp.get("human_approved") if cp else None,
"partial_progress": (cp.get("metadata") or {}).get("partial_progress") if cp else None,
"versions": len(versions) + (1 if cp else 0),
}
# Gate audit: a gated stage that completed without ever passing
# through awaiting_human (current or archived) was gate-skipped.
if (
stage_def["gated"]
and cp is not None
and cp.get("status") == "completed"
):
saw_wait = any(v.get("status") == "awaiting_human" for v in versions)
approved = bool(cp.get("human_approved"))
entry["gate_skipped"] = not (saw_wait or approved)
rail.append(entry)
# Checkpoints for stages the manifest doesn't declare (legacy runs,
# pipeline mismatch) still deserve a slot at the end.
for name, cp in checkpoints.items():
if name not in manifest_stage_names:
rail.append({
"name": name,
"gated": False,
"status": cp.get("status") or "unknown",
"timestamp": cp.get("timestamp"),
"review": cp.get("review"),
"cost_snapshot": cp.get("cost_snapshot"),
"error": cp.get("error"),
"human_approved": cp.get("human_approved"),
"partial_progress": None,
"versions": 1 + len(history.get(name, [])),
"undeclared": True,
})
return rail
# ---------------------------------------------------------------------------
# Artifacts
# ---------------------------------------------------------------------------
ARTIFACT_FILES = {
"research_brief": "research_brief.json",
"brief": "brief.json",
"proposal_packet": "proposal_packet.json",
"script": "script.json",
"scene_plan": "scene_plan.json",
"asset_manifest": "asset_manifest.json",
"edit_decisions": "edit_decisions.json",
"render_report": "render_report.json",
"final_review": "final_review.json",
"publish_log": "publish_log.json",
"decision_log": "decision_log.json",
}
def _collect_artifacts(project_dir: Path, checkpoints: dict[str, dict]) -> dict[str, dict]:
"""Artifacts from artifacts/*.json, backfilled from checkpoint payloads."""
artifacts: dict[str, dict] = {}
art_dir = project_dir / "artifacts"
for name, filename in ARTIFACT_FILES.items():
data = _read_json(art_dir / filename)
if data is not None:
artifacts[name] = data
# decision_log historically also lives at project root
if "decision_log" not in artifacts:
data = _read_json(project_dir / "decision_log.json")
if data is not None:
artifacts["decision_log"] = data
# Backfill from checkpoint-embedded artifacts.
for cp in checkpoints.values():
for name, value in (cp.get("artifacts") or {}).items():
if name not in artifacts:
resolved = _resolve_artifact(project_dir, value)
if resolved is not None:
artifacts[name] = resolved
return artifacts
# ---------------------------------------------------------------------------
# Storyboard join
# ---------------------------------------------------------------------------
def _asset_entry(project_dir: Path, asset: dict) -> dict:
"""Normalize a manifest asset entry + resolve file existence."""
raw_path = asset.get("path") or ""
file_path = Path(raw_path)
if not file_path.is_absolute():
file_path = project_dir / raw_path
exists = file_path.is_file()
kind = asset.get("type") or ""
if not kind and file_path.suffix:
ext = file_path.suffix.lower()
if ext in MEDIA_IMAGE_EXT:
kind = "image"
elif ext in MEDIA_VIDEO_EXT:
kind = "video"
elif ext in MEDIA_AUDIO_EXT:
kind = "audio"
return {
"id": asset.get("id"),
"type": kind,
"scene_id": asset.get("scene_id"),
"path": _rel(project_dir, file_path) if exists else raw_path,
"exists": exists,
"prompt": asset.get("prompt"),
"model": asset.get("model"),
"source_tool": asset.get("source_tool"),
"provider": asset.get("provider"),
"cost_usd": asset.get("cost_usd"),
"quality_score": asset.get("quality_score"),
"duration_seconds": asset.get("duration_seconds"),
"resolution": asset.get("resolution"),
}
def _find_script_section(scene: dict, sections: list[dict]) -> Optional[dict]:
"""Join scene → script section by id, falling back to timing overlap."""
sid = scene.get("script_section_id")
if sid:
for s in sections:
if s.get("id") == sid:
return s
start = scene.get("start_seconds")
end = scene.get("end_seconds")
if start is None or end is None:
return None
best, best_overlap = None, 0.0
for s in sections:
s0, s1 = s.get("start_seconds"), s.get("end_seconds")
if s0 is None or s1 is None:
continue
overlap = min(end, s1) - max(start, s0)
if overlap > best_overlap:
best, best_overlap = s, overlap
return best
def _build_storyboard(
project_dir: Path,
artifacts: dict[str, dict],
events: list[dict],
) -> Optional[dict]:
"""Scene cards: scene_plan × script × asset_manifest (+ live events)."""
scene_plan = artifacts.get("scene_plan")
if not scene_plan or not isinstance(scene_plan.get("scenes"), list):
return None
sections = (artifacts.get("script") or {}).get("sections") or []
manifest_assets = (artifacts.get("asset_manifest") or {}).get("assets") or []
assets_by_scene: dict[str, list[dict]] = {}
for asset in manifest_assets:
if not isinstance(asset, dict):
continue
entry = _asset_entry(project_dir, asset)
key = str(entry.get("scene_id") or "")
assets_by_scene.setdefault(key, []).append(entry)
# A scene is "generating" if its most recent event is an unfinished start.
generating: dict[str, dict] = {}
for ev in events:
sid = ev.get("scene_id")
if not sid:
continue
if ev.get("event") == "start":
generating[sid] = ev
elif ev.get("event") in ("finish", "error"):
generating.pop(sid, None)
cards = []
for scene in scene_plan["scenes"]:
if not isinstance(scene, dict):
continue
sid = str(scene.get("id") or "")
section = _find_script_section(scene, sections)
scene_assets = assets_by_scene.get(sid, [])
visuals = [a for a in scene_assets if a["type"] in ("image", "video", "diagram", "animation")]
audio = [a for a in scene_assets if a["type"] in ("audio", "narration", "music", "sfx")]
# Takes: multiple visual assets for the same slot, ordered as listed.
active_visual = visuals[-1] if visuals else None
cards.append({
"id": sid,
"type": scene.get("type"),
"description": scene.get("description"),
"start_seconds": scene.get("start_seconds"),
"end_seconds": scene.get("end_seconds"),
"duration_seconds": (
(scene.get("end_seconds") or 0) - (scene.get("start_seconds") or 0)
if scene.get("end_seconds") is not None and scene.get("start_seconds") is not None
else None
),
"hero_moment": bool(scene.get("hero_moment")),
"shot_language": scene.get("shot_language"),
"shot_intent": scene.get("shot_intent"),
"framing": scene.get("framing"),
"movement": scene.get("movement"),
"narration": (section or {}).get("text"),
"section_label": (section or {}).get("label"),
"required_assets": scene.get("required_assets") or [],
"visual": active_visual,
"takes": visuals,
"audio": audio,
"generating": generating.get(sid) is not None,
"generating_tool": (generating.get(sid) or {}).get("tool"),
})
total = scene_plan.get("metadata", {}).get("total_duration_seconds")
if total is None and cards:
ends = [c["end_seconds"] for c in cards if c["end_seconds"] is not None]
total = max(ends) if ends else None
return {
"scenes": cards,
"total_duration_seconds": total,
"style_playbook": scene_plan.get("style_playbook"),
}
# ---------------------------------------------------------------------------
# Media discovery
# ---------------------------------------------------------------------------
def _scan_media(project_dir: Path) -> dict[str, list[dict]]:
"""Discovered media files (renders, loose assets, snapshots)."""
renders: list[dict] = []
snapshots: list[dict] = []
music: list[dict] = []
renders_dir = project_dir / "renders"
if renders_dir.is_dir():
for f in sorted(renders_dir.iterdir()):
if f.suffix.lower() in MEDIA_VIDEO_EXT and f.is_file():
renders.append({"path": _rel(project_dir, f), "size": f.stat().st_size,
"mtime": f.stat().st_mtime})
# Atelier heuristic: deliverables at project root.
for f in sorted(project_dir.glob("*.mp4")):
renders.append({"path": _rel(project_dir, f), "size": f.stat().st_size,
"mtime": f.stat().st_mtime, "at_root": True})
for f in sorted(project_dir.glob("*.mp3")):
music.append({"path": _rel(project_dir, f), "at_root": True})
music_dir = project_dir / "assets" / "music"
if music_dir.is_dir():
for f in sorted(music_dir.iterdir()):
if f.suffix.lower() in MEDIA_AUDIO_EXT:
music.append({"path": _rel(project_dir, f)})
for dirname in ("snapshots", "verify"):
d = project_dir / dirname
if d.is_dir():
for f in sorted(d.iterdir()):
if f.suffix.lower() in MEDIA_IMAGE_EXT and f.is_file():
snapshots.append({"path": _rel(project_dir, f)})
renders.sort(key=lambda r: r.get("mtime", 0), reverse=True)
return {"renders": renders, "snapshots": snapshots, "music": music}
def _find_poster(project_dir: Path, state: dict) -> Optional[str]:
"""Best poster image for the library card."""
board = state.get("storyboard") or {}
for card in board.get("scenes", []):
visual = card.get("visual")
if visual and visual.get("exists") and visual.get("type") == "image":
return visual["path"]
for snap in (state.get("media") or {}).get("snapshots", []):
return snap["path"]
images_dir = project_dir / "assets" / "images"
if images_dir.is_dir():
for f in sorted(images_dir.iterdir()):
if f.suffix.lower() in MEDIA_IMAGE_EXT:
return _rel(project_dir, f)
return None
def _last_activity(project_dir: Path) -> float:
"""Most recent mtime among state-bearing files (bounded scan)."""
latest = 0.0
try:
candidates = list(project_dir.glob("checkpoint_*.json"))
candidates.append(project_dir / "events.jsonl")
art = project_dir / "artifacts"
if art.is_dir():
candidates.extend(art.glob("*.json"))
for p in candidates:
try:
latest = max(latest, p.stat().st_mtime)
except OSError:
continue
except OSError:
pass
return latest
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_board_state(project_dir: Path) -> dict[str, Any]:
"""Full BoardState for one project. Never raises."""
project_dir = Path(project_dir)
project_id = project_dir.name
marker = _read_json(project_dir / "project.json") or {}
meta_json = _read_json(project_dir / "meta.json") or {}
checkpoints = _collect_checkpoints(project_dir)
history = _collect_history(project_dir)
pipeline_type = marker.get("pipeline_type")
if not pipeline_type:
for cp in checkpoints.values():
pt = cp.get("pipeline_type")
if pt and pt != "unknown":
pipeline_type = pt
break
pipeline_meta = _load_pipeline_meta(pipeline_type)
artifacts = _collect_artifacts(project_dir, checkpoints)
events = read_events(project_dir, limit=250)
storyboard = _build_storyboard(project_dir, artifacts, events)
media = _scan_media(project_dir)
stages = _build_stage_rail(pipeline_meta, checkpoints, history)
# Cost: latest checkpoint snapshot wins; fall back to manifest total.
cost = None
for cp in sorted(checkpoints.values(), key=lambda c: c.get("_mtime", 0), reverse=True):
if cp.get("cost_snapshot"):
cost = cp["cost_snapshot"]
break
if cost is None:
total = (artifacts.get("asset_manifest") or {}).get("total_cost_usd")
if total is not None:
cost = {"total_spent_usd": total}
import time
last_activity = _last_activity(project_dir)
now = time.time()
state: dict[str, Any] = {
"project_id": project_id,
"title": marker.get("title") or meta_json.get("name") or project_id.replace("-", " ").title(),
"pipeline": pipeline_meta,
"style_playbook": marker.get("style_playbook"),
"created_at": marker.get("created_at"),
"has_marker": bool(marker),
"has_pipeline_state": bool(checkpoints),
"stages": stages,
"artifacts": artifacts,
"storyboard": storyboard,
"media": media,
"events": events,
"cost": cost,
"last_activity": last_activity,
"live": bool(last_activity and (now - last_activity) < LIVE_WINDOW_SECONDS),
}
state["poster"] = _find_poster(project_dir, state)
return state
def summarize_project(project_dir: Path) -> dict[str, Any]:
"""Cheap library-card summary (no full artifact parse of big files)."""
state = load_board_state(project_dir)
active = next((s for s in state["stages"] if s["status"] in ("in_progress", "awaiting_human")), None)
done = [s for s in state["stages"] if s["status"] == "completed"]
return {
"project_id": state["project_id"],
"title": state["title"],
"pipeline_type": state["pipeline"]["pipeline_type"],
"has_pipeline_state": state["has_pipeline_state"],
"poster": state["poster"],
"live": state["live"],
"last_activity": state["last_activity"],
"active_stage": active["name"] if active else None,
"awaiting_human": bool(active and active["status"] == "awaiting_human"),
"stage_states": [
{"name": s["name"], "status": s["status"]}
for s in state["stages"] if not s.get("undeclared")
],
"completed_count": len(done),
"render_count": len(state["media"]["renders"]),
"scene_count": len((state["storyboard"] or {}).get("scenes", [])),
}
def list_projects(projects_dir: Optional[Path] = None) -> list[dict[str, Any]]:
"""Library view: every project directory, live-first then recency."""
root = Path(projects_dir) if projects_dir else PROJECTS_DIR
if not root.is_dir():
return []
summaries = []
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith(("_", ".")):
continue
try:
summaries.append(summarize_project(entry))
except Exception:
summaries.append({
"project_id": entry.name,
"title": entry.name.replace("-", " ").title(),
"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",
})
summaries.sort(key=lambda s: (not s["live"], -(s["last_activity"] or 0)))
return summaries

View File

@@ -7,3 +7,8 @@ Pillow>=10.0
numpy>=1.24
requests>=2.31
google-auth>=2.0 # service-account auth for Google TTS + Imagen (Vertex AI)
# Backlot — the living storyboard (local board server)
fastapi>=0.110
uvicorn>=0.29
watchfiles>=0.21

190
tests/backlot/test_state.py Normal file
View File

@@ -0,0 +1,190 @@
"""Unit tests for Backlot BoardState derivation (backlot/state.py)."""
import json
import time
from pathlib import Path
import pytest
from backlot import state as state_mod
from backlot.state import list_projects, load_board_state, summarize_project
@pytest.fixture
def projects_root(tmp_path, monkeypatch):
root = tmp_path / "projects"
root.mkdir()
monkeypatch.setattr(state_mod, "PROJECTS_DIR", root)
return root
def _make_project(root: Path, pid: str) -> Path:
p = root / pid
(p / "artifacts").mkdir(parents=True)
(p / "assets" / "images").mkdir(parents=True)
(p / "renders").mkdir()
return p
def _write(p: Path, data: dict) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data), encoding="utf-8")
SCENE_PLAN = {
"version": "1.0",
"scenes": [
{"id": "sc1", "type": "generated", "description": "opening",
"start_seconds": 0, "end_seconds": 4, "script_section_id": "s1",
"hero_moment": False},
{"id": "sc2", "type": "generated", "description": "climax",
"start_seconds": 4, "end_seconds": 10, "hero_moment": True},
],
}
SCRIPT = {
"version": "1.0", "title": "Test Film", "total_duration_seconds": 10,
"sections": [
{"id": "s1", "text": "It begins.", "start_seconds": 0, "end_seconds": 4},
{"id": "s2", "text": "It ends.", "start_seconds": 4, "end_seconds": 10},
],
}
class TestBoardState:
def test_full_project(self, projects_root):
p = _make_project(projects_root, "film")
_write(p / "project.json", {"project_id": "film", "title": "My Film",
"pipeline_type": "cinematic", "created_at": "2026-01-01T00:00:00Z"})
_write(p / "artifacts" / "scene_plan.json", SCENE_PLAN)
_write(p / "artifacts" / "script.json", SCRIPT)
img = p / "assets" / "images" / "sc1.png"
img.write_bytes(b"fake")
_write(p / "artifacts" / "asset_manifest.json", {
"version": "1.0",
"assets": [
{"id": "a1", "type": "image", "path": "assets/images/sc1.png",
"scene_id": "sc1", "source_tool": "t", "cost_usd": 0.1},
{"id": "a2", "type": "image", "path": "assets/images/missing.png",
"scene_id": "sc2", "source_tool": "t"},
],
"total_cost_usd": 0.1,
})
_write(p / "checkpoint_script.json", {
"version": "1.0", "project_id": "film", "pipeline_type": "cinematic",
"stage": "script", "status": "completed", "timestamp": "2026-01-01T01:00:00Z",
"human_approved": True, "artifacts": {},
})
s = load_board_state(p)
assert s["title"] == "My Film"
assert s["pipeline"]["pipeline_type"] == "cinematic"
assert s["pipeline"]["known"] is True
board = s["storyboard"]
assert len(board["scenes"]) == 2
sc1, sc2 = board["scenes"]
assert sc1["narration"] == "It begins."
assert sc1["visual"]["exists"] is True
# sc2 has no script_section_id -> joined by timing overlap
assert sc2["narration"] == "It ends."
assert sc2["hero_moment"] is True
assert sc2["visual"]["exists"] is False # missing file flagged
script_stage = next(x for x in s["stages"] if x["name"] == "script")
assert script_stage["status"] == "completed"
def test_gate_skip_detection(self, projects_root):
p = _make_project(projects_root, "sneaky")
# completed on a gated stage with no awaiting_human history and no
# human_approved -> gate_skipped flag
_write(p / "checkpoint_script.json", {
"version": "1.0", "project_id": "sneaky", "pipeline_type": "cinematic",
"stage": "script", "status": "completed",
"timestamp": "2026-01-01T01:00:00Z", "artifacts": {},
})
s = load_board_state(p)
script_stage = next(x for x in s["stages"] if x["name"] == "script")
assert script_stage["gate_skipped"] is True
# with an archived awaiting_human version, the gate was honored
_write(p / "history" / "checkpoint_script_20260101.json", {
"stage": "script", "status": "awaiting_human",
})
s2 = load_board_state(p)
script_stage2 = next(x for x in s2["stages"] if x["name"] == "script")
assert script_stage2["gate_skipped"] is False
def test_generating_state_from_events(self, projects_root):
p = _make_project(projects_root, "live")
_write(p / "artifacts" / "scene_plan.json", SCENE_PLAN)
events = [
{"ts": "t1", "tool": "img", "event": "start", "scene_id": "sc1"},
{"ts": "t2", "tool": "img", "event": "finish", "scene_id": "sc1"},
{"ts": "t3", "tool": "img", "event": "start", "scene_id": "sc2"},
]
(p / "events.jsonl").write_text(
"\n".join(json.dumps(e) for e in events) + "\n", encoding="utf-8")
s = load_board_state(p)
cards = {c["id"]: c for c in s["storyboard"]["scenes"]}
assert cards["sc1"]["generating"] is False
assert cards["sc2"]["generating"] is True
assert cards["sc2"]["generating_tool"] == "img"
def test_degraded_project_never_crashes(self, projects_root):
p = projects_root / "bare"
p.mkdir()
(p / "something.mp4").write_bytes(b"x")
(p / "artifacts").mkdir()
(p / "artifacts" / "script.json").write_text("NOT JSON", encoding="utf-8")
s = load_board_state(p)
assert s["has_pipeline_state"] is False
assert s["storyboard"] is None
assert s["media"]["renders"][0]["path"] == "something.mp4"
assert s["media"]["renders"][0]["at_root"] is True
def test_undeclared_stage_surfaces(self, projects_root):
p = _make_project(projects_root, "legacy")
_write(p / "checkpoint_idea.json", {
"version": "1.0", "project_id": "legacy", "pipeline_type": "cinematic",
"stage": "idea", "status": "completed",
"timestamp": "2026-01-01T01:00:00Z", "artifacts": {},
})
s = load_board_state(p)
idea = next(x for x in s["stages"] if x["name"] == "idea")
assert idea.get("undeclared") is True
class TestLibrary:
def test_list_projects_sorts_live_first(self, projects_root):
old = _make_project(projects_root, "old-film")
_write(old / "checkpoint_script.json", {"stage": "script", "status": "completed"})
# backdate everything in old-film
import os
past = time.time() - 60 * 60 * 24 * 30
for f in old.rglob("*"):
if f.is_file():
os.utime(f, (past, past))
fresh = _make_project(projects_root, "fresh-film")
_write(fresh / "checkpoint_script.json", {"stage": "script", "status": "in_progress"})
projects = list_projects(projects_root)
assert [p["project_id"] for p in projects][0] == "fresh-film"
assert projects[0]["live"] is True
assert projects[1]["live"] is False
def test_underscore_dirs_skipped(self, projects_root):
(projects_root / "_analysis").mkdir()
_make_project(projects_root, "real")
ids = [p["project_id"] for p in list_projects(projects_root)]
assert ids == ["real"]
def test_summary_shape(self, projects_root):
p = _make_project(projects_root, "sum")
_write(p / "project.json", {"title": "Sum", "pipeline_type": "cinematic"})
_write(p / "checkpoint_script.json", {
"stage": "script", "status": "awaiting_human",
"timestamp": "2026-01-01T01:00:00Z", "artifacts": {},
})
summary = summarize_project(p)
assert summary["awaiting_human"] is True
assert summary["active_stage"] == "script"