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
This commit is contained in:
calesthio
2026-07-01 23:47:45 -07:00
parent c80ecbcb2d
commit 4b4d7b1e99
10 changed files with 1420 additions and 14 deletions

17
.claude/launch.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "backlot",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "backlot", "serve", "--port", "4750"],
"port": 4750
},
{
"name": "mockups",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "4788", "--bind", "127.0.0.1"],
"port": 4788
}
]
}

3
.gitignore vendored
View File

@@ -94,3 +94,6 @@ remotion-composer/public/demo-props/caption-burn-*
venv/
.venv/
# Backlot local cache (thumbnails)
.backlot/

View File

@@ -17,9 +17,11 @@ 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
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"}
@@ -51,6 +53,40 @@ class ChangeHub:
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)."""
@@ -80,6 +116,7 @@ async def _watch_projects() -> None:
if pid:
touched.add(pid)
for pid in touched:
_invalidate_summary(pid)
hub.publish(pid)
@@ -104,7 +141,7 @@ def create_app() -> FastAPI:
@app.get("/api/projects")
async def projects() -> list:
return await asyncio.to_thread(list_projects)
return await asyncio.to_thread(_cached_summaries)
@app.get("/api/project/{project_id}/state")
async def project_state(project_id: str) -> dict:
@@ -171,6 +208,24 @@ def create_app() -> FastAPI:
"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}")
@@ -214,4 +269,44 @@ 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()

View File

@@ -47,8 +47,8 @@ def _read_json(path: Path) -> Optional[dict]:
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.resolve().relative_to(Path(project_dir).resolve()).as_posix()
except (ValueError, OSError):
return path.name
@@ -226,13 +226,40 @@ def _collect_artifacts(project_dir: Path, checkpoints: dict[str, dict]) -> dict[
# Storyboard join
# ---------------------------------------------------------------------------
def _resolve_asset_path(project_dir: Path, raw_path: str) -> Optional[Path]:
"""Manifest paths appear in several real-world flavors — try them all.
Observed on disk: project-relative ("assets/images/x.png"),
repo-relative ("projects/<id>/assets/images/x.png"), and absolute.
"""
if not raw_path:
return None
p = Path(raw_path)
candidates = []
if p.is_absolute():
candidates.append(p)
else:
candidates.append(project_dir / raw_path)
candidates.append(REPO_ROOT / raw_path)
# repo-relative with the project prefix repeated
parts = p.parts
if len(parts) > 2 and parts[0] == "projects":
candidates.append(project_dir.parent / Path(*parts[1:]))
for c in candidates:
try:
if c.is_file():
return c
except OSError:
continue
return None
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()
resolved = _resolve_asset_path(project_dir, raw_path)
file_path = resolved if resolved is not None else (project_dir / raw_path)
exists = resolved is not None
kind = asset.get("type") or ""
if not kind and file_path.suffix:
ext = file_path.suffix.lower()
@@ -400,7 +427,8 @@ def _scan_media(project_dir: Path) -> dict[str, list[dict]]:
def _find_poster(project_dir: Path, state: dict) -> Optional[str]:
"""Best poster image for the library card."""
"""Best poster for the library card (image path, or a video path —
the /thumb endpoint extracts a frame from videos)."""
board = state.get("storyboard") or {}
for card in board.get("scenes", []):
visual = card.get("visual")
@@ -408,11 +436,21 @@ def _find_poster(project_dir: Path, state: dict) -> Optional[str]:
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)
# Common image homes, in order of how representative they usually are.
for rel_dir in ("assets/images", "assets/frames", "exports", "assets", "."):
d = (project_dir / rel_dir) if rel_dir != "." else project_dir
if not d.is_dir():
continue
try:
for f in sorted(d.iterdir()):
if f.is_file() and f.suffix.lower() in MEDIA_IMAGE_EXT:
return _rel(project_dir, f)
except OSError:
continue
# Last resort: the newest render — /thumb extracts a poster frame.
renders = (state.get("media") or {}).get("renders", [])
if renders:
return renders[0]["path"]
return None

516
backlot/ui/board.css Normal file
View File

@@ -0,0 +1,516 @@
/* ============================================================
BACKLOT — Living Storyboard design system (mockup)
Dark-room editorial: near-black matte canvas, artifacts glow.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap');
:root {
--bg: #0a0a0c;
--surface: #101013;
--surface-2: #16161a;
--surface-3: #1c1c21;
--border: #232329;
--border-soft: #1a1a1f;
--text: #ececef;
--text-2: #a0a0a9;
--text-3: #5f5f68;
--amber: #f0a83c;
--amber-dim: rgba(240, 168, 60, 0.14);
--green: #4fc283;
--green-dim: rgba(79, 194, 131, 0.12);
--red: #e5544b;
--red-dim: rgba(229, 84, 75, 0.12);
--blue: #6aa1ff;
--cream: #f2e9d5;
--cream-shade: #e5d9be;
--cream-ink: #29231a;
--cream-ink-2: #6b5f4a;
--sans: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--screenplay: 'Courier Prime', 'Courier New', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { color-scheme: dark; }
::-webkit-scrollbar { width: 10px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #26262e; border-radius: 6px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: #34343e; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
/* faint vignette so media pops */
background-image: radial-gradient(1200px 600px at 50% -100px, #111116 0%, var(--bg) 70%);
}
.wrap { max-width: 1440px; margin: 0 auto; padding: 0 28px 80px; }
/* film grain — barely-there, keeps the dark room from feeling flat */
body::after {
content: ''; position: fixed; inset: -50%; pointer-events: none; z-index: 90;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: .035; animation: grain 1.2s steps(4) infinite;
}
@keyframes grain {
0%, 100% { transform: translate(0,0); }
25% { transform: translate(-1.5%, 1%); }
50% { transform: translate(1%, -1.5%); }
75% { transform: translate(-1%, -1%); }
}
/* everything enters like a story unfolding */
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.slate { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; }
.rail .stage { animation: rise .5s cubic-bezier(.2,.7,.3,1) backwards; }
.rail .stage:nth-child(1) { animation-delay: .06s } .rail .stage:nth-child(2) { animation-delay: .11s }
.rail .stage:nth-child(3) { animation-delay: .16s } .rail .stage:nth-child(4) { animation-delay: .21s }
.rail .stage:nth-child(5) { animation-delay: .26s } .rail .stage:nth-child(6) { animation-delay: .31s }
.rail .stage:nth-child(7) { animation-delay: .36s } .rail .stage:nth-child(8) { animation-delay: .41s }
.script-card, .notice { animation: rise .6s cubic-bezier(.2,.7,.3,1) .25s backwards; }
aside .panel { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; }
aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2) { animation-delay: .42s }
.scene-card { animation: rise .65s cubic-bezier(.2,.7,.3,1) backwards; }
.scene-card:nth-child(1) { animation-delay: .35s } .scene-card:nth-child(2) { animation-delay: .43s }
.scene-card:nth-child(3) { animation-delay: .51s } .scene-card:nth-child(4) { animation-delay: .59s }
.scene-card:nth-child(5) { animation-delay: .67s } .scene-card:nth-child(6) { animation-delay: .75s }
.scene-card:nth-child(7) { animation-delay: .83s } .scene-card:nth-child(8) { animation-delay: .91s }
.scene-card:nth-child(9) { animation-delay: .99s } .scene-card:nth-child(10) { animation-delay: 1.07s }
.scene-card:nth-child(11) { animation-delay: 1.15s } .scene-card:nth-child(12) { animation-delay: 1.23s }
.lib-card { animation: rise .6s cubic-bezier(.2,.7,.3,1) backwards; }
.lib-card:nth-child(1) { animation-delay: .08s } .lib-card:nth-child(2) { animation-delay: .15s }
.lib-card:nth-child(3) { animation-delay: .22s } .lib-card:nth-child(4) { animation-delay: .29s }
.lib-card:nth-child(5) { animation-delay: .36s } .lib-card:nth-child(6) { animation-delay: .43s }
.lib-card:nth-child(7) { animation-delay: .50s } .lib-card:nth-child(8) { animation-delay: .57s }
/* ---------- header slate ---------- */
.slate {
display: flex; align-items: center; gap: 18px;
padding: 18px 0 16px;
border-bottom: 1px solid var(--border-soft);
}
.clapper {
width: 34px; height: 26px; border-radius: 4px; flex: none;
background: repeating-linear-gradient(-45deg, #2c2c33 0 6px, #101013 6px 12px);
border: 1px solid var(--border);
}
.slate h1 {
font-family: var(--mono); font-size: 17px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
}
.slate .wordmark {
font-family: var(--mono); font-size: 11px; letter-spacing: 0.22em;
color: var(--text-3); text-transform: uppercase; margin-right: 2px;
}
.chip {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.06em;
padding: 3px 9px; border-radius: 99px;
border: 1px solid var(--border); color: var(--text-2);
white-space: nowrap;
}
.chip.warn { border-color: rgba(240,168,60,.4); color: var(--amber); background: var(--amber-dim); }
.slate .spacer { flex: 1; }
.live {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
color: var(--amber);
}
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s ease-in-out infinite; }
.live.idle { color: var(--text-3); }
.live.idle .dot { background: var(--text-3); animation: none; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.5); opacity: 1; }
50% { box-shadow: 0 0 0 7px rgba(240,168,60,0); opacity: .75; }
}
.cost { text-align: right; }
.cost .nums { font-family: var(--mono); font-size: 13px; }
.cost .nums b { color: var(--text); font-weight: 600; }
.cost .nums span { color: var(--text-3); }
.cost .bar { width: 150px; height: 3px; background: var(--surface-3); border-radius: 3px; margin-top: 5px; overflow: hidden; }
.cost .bar i { display: block; height: 100%; background: var(--green); border-radius: 3px; }
.cost .bar i.warn { background: var(--amber); }
.cost .label { font-size: 10px; color: var(--text-3); letter-spacing: .08em; text-transform: uppercase; margin-top: 3px; }
/* ---------- stage rail ---------- */
.rail { display: flex; align-items: flex-start; padding: 26px 0 22px; }
.stage { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; min-width: 0; }
.stage .node {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; z-index: 2; position: relative;
background: var(--surface-2); border: 1.5px solid var(--border);
color: var(--text-3);
}
.stage .line {
position: absolute; top: 13px; left: calc(-50% + 13px); right: calc(50% + 13px);
height: 1.5px; background: var(--border);
}
.stage:first-child .line { display: none; }
.stage .name {
margin-top: 10px; font-family: var(--mono); font-size: 11px;
letter-spacing: 0.05em; color: var(--text-3);
}
.stage .sub { font-size: 10.5px; color: var(--text-3); margin-top: 3px; text-align: center; max-width: 150px; }
.stage.done .node { background: var(--surface-3); border-color: #3a3a42; color: var(--green); }
.stage.done .line { background: #3a3a42; }
.stage.done .name { color: var(--text-2); }
.stage.active .node {
border-color: var(--amber); color: var(--amber); background: var(--amber-dim);
animation: ringpulse 1.8s ease-in-out infinite;
}
.stage.active .line { background: linear-gradient(90deg, #3a3a42, rgba(240,168,60,.55)); overflow: hidden; }
.stage.active .line::after { /* energy traveling toward the live stage */
content: ''; position: absolute; top: 0; bottom: 0; width: 34px; left: -40px;
background: linear-gradient(90deg, transparent, rgba(240,168,60,.95), transparent);
animation: travel 1.7s ease-in-out infinite;
}
@keyframes travel { to { left: calc(100% + 6px); } }
.stage.active .name { color: var(--amber); font-weight: 600; }
.stage.active .sub { color: var(--text-2); }
@keyframes ringpulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(240,168,60,.45); }
50% { box-shadow: 0 0 0 9px rgba(240,168,60,0); }
}
.stage.await .node { border-color: var(--amber); color: var(--amber); background: var(--amber-dim); box-shadow: 0 0 18px rgba(240,168,60,.25); }
.stage.await .line { background: linear-gradient(90deg, #3a3a42, var(--amber)); }
.stage.await .name { color: var(--amber); font-weight: 600; }
.stage.await .sub { color: var(--amber); }
.stage.failed .node { border-color: var(--red); color: var(--red); background: var(--red-dim); }
.stage.failed .name { color: var(--red); }
/* ---------- layout ---------- */
.board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; }
.main-col { min-width: 0; }
.panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; }
.panel + .panel { margin-top: 18px; }
.panel-head {
display: flex; align-items: baseline; gap: 10px;
padding: 13px 16px 11px; border-bottom: 1px solid var(--border-soft);
}
.panel-head h2 { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.18em; color: var(--text-2); text-transform: uppercase; }
.panel-head .meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: auto; }
.panel-body { padding: 14px 16px; }
/* ---------- screenplay card ---------- */
.script-card {
background: linear-gradient(178deg, var(--cream) 0%, var(--cream-shade) 130%);
color: var(--cream-ink);
border-radius: 6px;
padding: 34px 44px 26px;
max-width: 700px; /* screenplay pages are narrow — paper on a dark desk */
margin: 0 auto;
font-family: var(--screenplay);
box-shadow: 0 18px 50px -18px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) inset;
position: relative;
cursor: pointer;
}
.script-card::after { /* page edge */
content: ''; position: absolute; right: 7px; top: 7px; bottom: 7px; width: 1px;
background: rgba(0,0,0,.07);
}
.script-card .sp-title {
text-align: center; font-weight: 700; font-size: 16px;
letter-spacing: 0.12em; text-transform: uppercase;
margin-bottom: 4px;
}
.script-card .sp-meta { text-align: center; font-size: 11.5px; color: var(--cream-ink-2); margin-bottom: 26px; }
.script-card .sp-slug {
font-weight: 700; font-size: 12.5px; text-transform: uppercase;
letter-spacing: 0.04em; margin: 18px 0 6px;
}
.script-card .sp-slug .tc { color: var(--cream-ink-2); font-weight: 400; float: right; font-size: 11px; }
.script-card .sp-action { font-size: 13px; line-height: 1.62; }
.script-card .sp-paren { font-size: 11.5px; font-style: italic; color: var(--cream-ink-2); margin: 4px 0 0 42px; }
.script-card .sp-cue {
display: inline-block; font-family: var(--mono); font-size: 9.5px; font-style: normal;
background: rgba(0,0,0,.06); border-radius: 3px; padding: 1px 6px; margin: 6px 0 0;
color: #7d6f52; letter-spacing: .03em;
}
.script-card .sp-fade { text-align: right; font-size: 12px; font-weight: 700; margin-top: 20px; text-transform: uppercase; }
.script-card .sp-expand {
position: absolute; right: 16px; bottom: 12px;
font-family: var(--mono); font-size: 10px; color: var(--cream-ink-2); letter-spacing: .06em;
}
.script-approved {
position: absolute; top: 20px; right: 26px;
font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .14em;
color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px;
padding: 3px 8px; transform: rotate(6deg); opacity: .8;
}
/* ---------- right rail: decisions & activity ---------- */
.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); }
.decision:last-child { border-bottom: none; }
.decision .d-head { display: flex; gap: 8px; align-items: baseline; }
.decision .d-cat { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; text-transform: uppercase; }
.decision .d-pick { font-size: 12.5px; font-weight: 600; margin-top: 3px; }
.decision .d-pick .arrow { color: var(--amber); font-weight: 400; }
.decision .d-why { font-size: 11.5px; color: var(--text-2); margin-top: 3px; line-height: 1.45; }
.decision .d-alt { font-size: 10.5px; color: var(--text-3); margin-top: 4px; }
.decision .d-alt s { opacity: .8; }
.act-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); font-size: 11px; }
.act-row:last-child { border-bottom: none; }
.act-row .t { color: var(--text-3); font-size: 10px; flex: none; }
.act-row .tool { color: var(--text-2); }
.act-row .target { color: var(--text-3); }
.act-row .status { margin-left: auto; flex: none; font-size: 10.5px; }
.act-row .status.ok { color: var(--green); }
.act-row .status.run { color: var(--amber); animation: blink 1.4s ease-in-out infinite; }
.act-row .status.err { color: var(--red); }
@keyframes blink { 50% { opacity: .45; } }
/* ---------- filmstrip ---------- */
.strip-outer { position: relative; }
.filmstrip {
display: flex; gap: 12px; overflow-x: auto; padding: 26px 4px;
/* sprocket holes */
background:
radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 6px / 26px 10px repeat-x,
radial-gradient(circle 3.5px, #2e2e36 97%, transparent) 0 calc(100% - 16px) / 26px 10px repeat-x;
}
.filmstrip { scrollbar-width: thin; scrollbar-color: #26262e transparent; }
.scene-card { flex: none; display: flex; flex-direction: column; position: relative; }
.scene-card .sc-slate {
display: flex; align-items: baseline; gap: 8px;
font-family: var(--mono); font-size: 10px; letter-spacing: .05em;
color: var(--text-3); padding: 0 2px 6px;
}
.scene-card .sc-slate .num { color: var(--text-2); font-weight: 600; }
.scene-card .sc-slate .take { color: var(--amber); }
.scene-card .sc-slate .dur { margin-left: auto; }
.scene-card .sc-slate .hero { color: var(--amber); letter-spacing: .1em; }
.thumb {
border-radius: 7px; overflow: hidden; position: relative;
aspect-ratio: 16 / 9; background: var(--surface-2);
border: 1px solid var(--border);
}
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb .badge {
position: absolute; left: 7px; bottom: 7px;
font-family: var(--mono); font-size: 9px; letter-spacing: .06em;
background: rgba(8,8,10,.72); color: var(--text-2);
padding: 2px 7px; border-radius: 3px; backdrop-filter: blur(4px);
}
.thumb .play {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,.85); font-size: 26px; text-shadow: 0 2px 12px rgba(0,0,0,.7);
opacity: 0; transition: opacity .18s;
}
.thumb:hover .play { opacity: 1; }
.thumb.approved { border-color: rgba(79,194,131,.35); }
/* generating shimmer */
.thumb.generating { border-color: rgba(240,168,60,.45); }
.thumb.generating .shimmer {
position: absolute; inset: 0;
background: linear-gradient(100deg, var(--surface-2) 32%, #24242c 48%, var(--surface-2) 64%);
background-size: 220% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer { to { background-position: -120% 0; } }
.thumb.generating .gen-label {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 6px; padding: 0 14px; text-align: center;
font-family: var(--mono); font-size: 10px; color: var(--amber); letter-spacing: .08em;
}
.thumb.generating .gen-label .sub { color: var(--text-3); font-size: 9.5px; letter-spacing: .03em; line-height: 1.5; }
/* pending spec card */
.thumb.spec { border-style: dashed; border-color: #2c2c34; background: transparent; }
.thumb.spec .spec-in {
position: absolute; inset: 0; padding: 10px 12px;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
}
.thumb.spec .spec-desc { font-size: 10.5px; color: var(--text-3); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.thumb.spec .spec-shot { font-family: var(--mono); font-size: 9px; color: #4a4a54; letter-spacing: .04em; }
/* missing asset */
.thumb.missing { border-color: rgba(240,168,60,.55); border-style: dashed; background: var(--amber-dim); }
.thumb.missing .spec-in { align-items: center; text-align: center; }
.thumb.missing .warn-ic { color: var(--amber); font-size: 15px; }
.thumb.missing .spec-desc { color: var(--amber); -webkit-line-clamp: 2; }
/* text-card scene (typographic placeholder) */
.thumb.textcard { display: flex; align-items: center; justify-content: center; background: #0d0d10; }
.thumb.textcard .tc-copy {
font-family: var(--mono); font-weight: 500; text-align: center;
letter-spacing: .2em; font-size: 11px; color: #d8d8de; padding: 0 10px;
}
.narr {
padding: 8px 3px 0; font-size: 11px; color: var(--text-2); line-height: 1.45;
font-style: italic; max-height: 52px; overflow: hidden;
}
.narr.tc-note { color: var(--text-3); }
.wave { display: flex; align-items: flex-end; gap: 1.5px; height: 14px; padding: 6px 3px 0; }
.wave i { width: 2.5px; background: #3d3d47; border-radius: 1px; }
.wave.played i { background: #565664; }
.wave .wv-time { font-family: var(--mono); font-size: 9px; color: var(--text-3); margin-left: 6px; align-self: center; }
/* takes drawer */
.takes { display: flex; gap: 5px; padding: 8px 2px 0; align-items: center; }
.takes .tk { width: 44px; aspect-ratio: 16/9; border-radius: 3px; overflow: hidden; border: 1px solid var(--border); opacity: .55; position: relative; }
.takes .tk img { width: 100%; height: 100%; object-fit: cover; }
.takes .tk.active { opacity: 1; border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber); }
.takes .tk-label { font-family: var(--mono); font-size: 9px; color: var(--text-3); letter-spacing: .05em; }
/* ---------- empty state ---------- */
.empty {
border: 1.5px dashed #26262e; border-radius: 10px; padding: 40px;
text-align: center; color: var(--text-3);
}
.empty .big { font-family: var(--mono); font-size: 12px; letter-spacing: .12em; text-transform: uppercase; margin-bottom: 6px; color: #4a4a54; }
/* ---------- review findings ---------- */
.findings { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: 10.5px; }
.findings .f { padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3); }
.findings .f.crit { color: var(--red); border-color: rgba(229,84,75,.35); }
.findings .f.sugg { color: var(--amber); border-color: rgba(240,168,60,.3); }
/* ---------- modal ---------- */
.modal-bg {
position: fixed; inset: 0; background: rgba(5,5,7,.82); backdrop-filter: blur(6px);
display: none; align-items: flex-start; justify-content: center; overflow-y: auto;
padding: 48px 20px; z-index: 50;
}
.modal-bg.open { display: flex; }
.modal-page { max-width: 640px; width: 100%; }
.modal-close {
position: fixed; top: 18px; right: 26px; font-family: var(--mono);
color: var(--text-2); font-size: 12px; cursor: pointer; letter-spacing: .1em;
background: var(--surface-2); border: 1px solid var(--border); border-radius: 99px; padding: 6px 14px;
}
/* ---------- library ---------- */
.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; padding-top: 24px; }
.lib-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; overflow: hidden; transition: border-color .15s, transform .15s; }
.lib-card:hover { border-color: #34343e; transform: translateY(-2px); }
.lib-card.live-card { border-color: rgba(240,168,60,.4); }
.lib-poster { aspect-ratio: 16/9; background: var(--surface-2); position: relative; overflow: hidden; }
.lib-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lib-poster .lp-live {
position: absolute; top: 9px; left: 9px; font-family: var(--mono); font-size: 9px; letter-spacing: .12em;
color: var(--amber); background: rgba(8,8,10,.75); border: 1px solid rgba(240,168,60,.45);
padding: 3px 8px; border-radius: 99px; display: flex; gap: 5px; align-items: center; backdrop-filter: blur(4px);
}
.lib-poster .lp-live .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--amber); animation: pulse 1.6s infinite; }
.lib-poster .lp-txt { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--mono); letter-spacing: .16em; font-size: 12px; color: #3f3f4a; }
.lib-body { padding: 13px 15px 14px; }
.lib-body h3 { font-family: var(--mono); font-size: 12.5px; font-weight: 600; letter-spacing: .05em; }
.lib-body .lb-meta { display: flex; gap: 8px; margin-top: 5px; align-items: center; }
.lib-body .lb-meta .chip { font-size: 9.5px; padding: 2px 7px; }
.lib-body .lb-meta .when { font-size: 10.5px; color: var(--text-3); margin-left: auto; }
.mini-rail { display: flex; gap: 4px; margin-top: 11px; align-items: center; }
.mini-rail i { height: 4px; flex: 1; border-radius: 2px; background: var(--surface-3); }
.mini-rail i.d { background: #3d5c4b; }
.mini-rail i.a { background: var(--amber); animation: blink 1.4s infinite; }
.mini-rail i.w { background: var(--amber); }
/* ---------- misc ---------- */
.notice {
display: flex; gap: 10px; align-items: center;
border: 1px solid rgba(240,168,60,.3); background: var(--amber-dim);
border-radius: 9px; padding: 11px 15px; font-size: 12.5px; color: var(--text-2); margin: 18px 0 4px;
}
.notice b { color: var(--amber); font-weight: 600; }
.section-title {
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .18em;
text-transform: uppercase; color: var(--text-2); padding: 26px 0 2px;
display: flex; align-items: baseline; gap: 12px;
}
.section-title .meta { font-size: 10.5px; color: var(--text-3); font-weight: 400; letter-spacing: .05em; margin-left: auto; }
a.backlink { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); text-decoration: none; letter-spacing: .08em; }
a.backlink:hover { color: var(--text-2); }
/* ============================================================
Live-board additions (beyond the mockup design system)
============================================================ */
/* stage nodes are interactive on the real board */
.stage { cursor: pointer; border-radius: 8px; padding: 4px 2px; transition: background .15s; }
.stage:hover { background: rgba(255,255,255,.025); }
.stage.selected .name { text-decoration: underline; text-underline-offset: 4px; }
/* stage drawer */
.drawer {
border: 1px solid var(--border-soft); background: var(--surface);
border-radius: 12px; margin: 0 0 20px; overflow: hidden;
animation: rise .35s cubic-bezier(.2,.7,.3,1);
}
.drawer .drawer-head {
display: flex; gap: 10px; align-items: baseline;
padding: 12px 16px; border-bottom: 1px solid var(--border-soft);
}
.drawer .drawer-head h3 { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; }
.drawer .drawer-head .close { margin-left: auto; cursor: pointer; color: var(--text-3); font-family: var(--mono); font-size: 11px; }
.drawer .drawer-head .close:hover { color: var(--text-2); }
.drawer .drawer-body { padding: 14px 16px; }
.drawer pre {
font-family: var(--mono); font-size: 11px; line-height: 1.55; color: var(--text-2);
background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: 8px;
padding: 12px 14px; overflow: auto; max-height: 420px; white-space: pre-wrap;
}
.gate-chip {
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em;
padding: 2px 8px; border-radius: 99px; border: 1px solid rgba(229,84,75,.45);
color: var(--red); background: var(--red-dim);
}
.ver-chip {
font-family: var(--mono); font-size: 9.5px; letter-spacing: .06em;
padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border); color: var(--text-3);
}
/* render section */
.render-hero { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #000; }
.render-hero video { width: 100%; display: block; max-height: 560px; }
.render-meta { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); padding: 8px 2px; display: flex; gap: 14px; flex-wrap: wrap; }
.render-meta .v { color: var(--text-2); cursor: pointer; }
.render-meta .v.active { color: var(--amber); }
/* audio playback affordance */
.narr-audio { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text-3); }
.narr-audio:hover { color: var(--amber); }
/* found-media grids (degraded view) */
.found-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.found-grid .thumb { aspect-ratio: 16/9; }
/* replay bar (phase 3) */
.replay-bar {
display: flex; align-items: center; gap: 14px;
border: 1px solid var(--border-soft); background: var(--surface);
border-radius: 10px; padding: 10px 16px; margin: 14px 0;
}
.replay-bar input[type=range] { flex: 1; accent-color: var(--amber); }
.replay-bar .rp-btn {
font-family: var(--mono); font-size: 11px; letter-spacing: .08em; cursor: pointer;
border: 1px solid var(--border); border-radius: 99px; padding: 4px 12px; color: var(--text-2);
background: var(--surface-2);
}
.replay-bar .rp-btn:hover { color: var(--amber); border-color: rgba(240,168,60,.4); }
.replay-bar .rp-time { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); min-width: 130px; text-align: right; }
body.replaying .live .dot { background: var(--blue); animation: none; }
/* filmstrip thumbs at fixed height (duration drives width) */
.filmstrip .thumb { height: 118px; aspect-ratio: auto; }
/* empty board hints */
.hint { font-size: 12px; color: var(--text-3); padding: 10px 2px; }
a { color: inherit; }

15
backlot/ui/board.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backlot</title>
<link rel="stylesheet" href="/ui/board.css">
</head>
<body>
<div class="wrap" id="app"></div>
<div class="modal-bg" id="modal"></div>
<audio id="player"></audio>
<script type="module" src="/ui/board.js"></script>
</body>
</html>

531
backlot/ui/board.js Normal file
View File

@@ -0,0 +1,531 @@
// Backlot project board — renders BoardState and stays live via SSE.
import {
STAGE_ICONS, el, fmtAgo, fmtClock, fmtDuration, fmtMoney,
getJSON, mediaURL, subscribe, thumbURL, waveBars,
} from "/ui/lib.js";
const projectId = decodeURIComponent(location.pathname.split("/p/")[1] || "");
const app = document.getElementById("app");
const modal = document.getElementById("modal");
const player = document.getElementById("player");
let state = null;
let selectedStage = null; // stage drawer open for this stage name
let activeRender = 0;
// ---------------------------------------------------------------------------
// header slate
// ---------------------------------------------------------------------------
function renderSlate(s) {
const board = s.storyboard;
const chips = [
el("span", { class: "chip" }, `${s.pipeline.pipeline_type} pipeline`),
board && board.total_duration_seconds
? el("span", { class: "chip" }, `${board.scenes.length} scenes · ${fmtDuration(board.total_duration_seconds)}`)
: null,
s.style_playbook ? el("span", { class: "chip" }, s.style_playbook) : null,
];
const awaiting = s.stages.find((x) => x.status === "awaiting_human");
const inProgress = s.stages.find((x) => x.status === "in_progress");
let liveEl;
if (awaiting) {
liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "◈ AWAITING YOU");
} else if (s.live || inProgress) {
liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "LIVE");
} else {
liveEl = el("span", { class: "live idle" }, el("span", { class: "dot" }),
`IDLE${s.last_activity ? " · " + fmtAgo(s.last_activity).toUpperCase() : ""}`);
}
const cost = el("div", { class: "cost" });
if (s.cost) {
const spent = s.cost.total_spent_usd ?? 0;
const budget = spent + (s.cost.budget_remaining_usd ?? 0);
const hasBudget = s.cost.budget_remaining_usd != null;
const pct = hasBudget && budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
cost.append(
el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)),
hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : null),
hasBudget
? el("div", { class: "bar" }, el("i", {
class: pct > 75 ? "warn" : "", style: `width:${pct}%`,
}))
: null,
el("div", { class: "label" }, "generation spend"),
);
}
return el("header", { class: "slate" },
el("div", { class: "clapper" }),
el("div", {},
el("a", { class: "wordmark", href: "/", style: "text-decoration:none" }, "Backlot"),
el("h1", {}, s.title),
),
...chips,
el("div", { class: "spacer" }),
liveEl,
cost,
);
}
// ---------------------------------------------------------------------------
// stage rail
// ---------------------------------------------------------------------------
function stageSub(st) {
if (st.status === "awaiting_human") return "awaiting your approval\nreply in chat to continue";
if (st.status === "in_progress" && st.partial_progress) {
const done = st.partial_progress.completed_scene_ids;
if (Array.isArray(done)) return `${done.length} scene${done.length === 1 ? "" : "s"} done`;
return "in progress";
}
if (st.status === "in_progress") return "in progress";
if (st.status === "failed") return st.error ? String(st.error).slice(0, 60) : "failed";
if (st.timestamp) {
const approved = st.gated && st.human_approved ? " · approved" : "";
return fmtClock(st.timestamp) + approved;
}
return "";
}
function renderRail(s) {
const rail = el("nav", { class: "rail" });
let pendingIndex = 1;
for (const st of s.stages) {
const cls = st.status === "completed" ? "done"
: st.status === "in_progress" ? "active"
: st.status === "awaiting_human" ? "await"
: st.status === "failed" ? "failed" : "";
const icon = STAGE_ICONS[st.status] || String(pendingIndex);
if (!STAGE_ICONS[st.status]) pendingIndex += 1;
const node = el("div", {
class: `stage ${cls}${selectedStage === st.name ? " selected" : ""}`,
onclick: () => toggleDrawer(st.name),
},
el("span", { class: "line" }),
el("span", { class: "node" }, icon),
el("span", { class: "name" }, st.name),
el("span", { class: "sub", style: "white-space:pre-line" }, stageSub(st)),
);
rail.append(node);
}
return rail;
}
function toggleDrawer(stageName) {
selectedStage = selectedStage === stageName ? null : stageName;
render();
}
const STAGE_ARTIFACTS = {
research: ["research_brief"],
proposal: ["proposal_packet"],
idea: ["brief"],
script: ["script"],
scene_plan: ["scene_plan"],
assets: ["asset_manifest"],
edit: ["edit_decisions"],
compose: ["render_report", "final_review"],
publish: ["publish_log"],
};
function renderDrawer(s) {
if (!selectedStage) return null;
const st = s.stages.find((x) => x.name === selectedStage);
if (!st) return null;
const body = el("div", { class: "drawer-body" });
if (st.review) {
body.append(el("div", { class: "findings", style: "margin-bottom:12px" },
el("span", { class: `f ${st.review.critical ? "crit" : ""}` }, `${st.review.critical ?? 0} critical`),
el("span", { class: `f ${st.review.suggestions ? "sugg" : ""}` }, `${st.review.suggestions ?? 0} suggestions`),
el("span", { class: "f" }, `${st.review.nitpicks ?? 0} nitpicks`),
typeof st.review.summary === "string" ? el("span", { style: "font-size:11.5px;color:var(--text-2);margin-left:8px" }, st.review.summary) : null,
));
}
const names = STAGE_ARTIFACTS[st.name] || [];
let shown = false;
for (const name of names) {
const artifact = s.artifacts[name];
if (!artifact) continue;
shown = true;
body.append(
el("div", { class: "d-cat", style: "font-family:var(--mono);font-size:9.5px;color:var(--text-3);letter-spacing:.1em;text-transform:uppercase;margin:6px 0 4px" }, name),
el("pre", {}, JSON.stringify(artifact, null, 2)),
);
}
if (!shown) {
body.append(el("div", { class: "hint" },
st.status === "pending" ? "This stage hasn't run yet." : "No canonical artifact found on disk for this stage."));
}
return el("div", { class: "drawer" },
el("div", { class: "drawer-head" },
el("h3", {}, `${st.name}${st.status}`),
st.gate_skipped ? el("span", { class: "gate-chip" }, "⚑ GATE SKIPPED") : null,
st.versions > 1 ? el("span", { class: "ver-chip" }, `v${st.versions}`) : null,
st.timestamp ? el("span", { class: "meta", style: "font-family:var(--mono);font-size:10.5px;color:var(--text-3)" }, st.timestamp) : null,
el("span", { class: "close", onclick: () => toggleDrawer(st.name) }, "CLOSE ✕"),
),
body,
);
}
// ---------------------------------------------------------------------------
// script card
// ---------------------------------------------------------------------------
function scriptSections(script, limit) {
const sections = script.sections || [];
const shown = limit ? sections.slice(0, limit) : sections;
const nodes = [];
for (const sec of shown) {
nodes.push(el("div", { class: "sp-slug" },
`${(sec.id || "").toUpperCase()}${sec.label || "Section"} `,
el("span", { class: "tc" }, `${fmtDuration(sec.start_seconds)} ${fmtDuration(sec.end_seconds)}`)));
if (sec.text) nodes.push(el("div", { class: "sp-action" }, sec.text));
if (sec.speaker_directions) nodes.push(el("div", { class: "sp-paren" }, `(${sec.speaker_directions})`));
const cues = sec.enhancement_cues || [];
if (cues.length) {
nodes.push(el("div", { style: "margin-left:42px" },
cues.map((c) => el("span", { class: "sp-cue" }, `${c.type} · ${String(c.description || "").slice(0, 60)}`))));
}
}
if (limit && sections.length > limit) {
nodes.push(el("div", { class: "sp-fade" }, `${sections.length - limit} more sections`));
}
return nodes;
}
function renderScriptCard(s) {
const script = s.artifacts.script;
if (!script) return null;
const scriptStage = s.stages.find((x) => x.name === "script");
const approved = scriptStage && scriptStage.status === "completed";
const card = el("div", { class: "script-card", title: "Click to expand full script", onclick: openScriptModal },
approved ? el("span", { class: "script-approved" }, "APPROVED") : null,
el("div", { class: "sp-title" }, script.title || s.title),
el("div", { class: "sp-meta" },
`script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`),
...scriptSections(script, 4),
el("span", { class: "sp-expand" }, "⤢ EXPAND SCRIPT"),
);
return card;
}
function openScriptModal() {
const script = state && state.artifacts.script;
if (!script) return;
modal.innerHTML = "";
modal.append(
el("span", { class: "modal-close", onclick: closeModal }, "ESC · CLOSE"),
el("div", { class: "modal-page" },
el("div", { class: "script-card", style: "cursor:default" },
el("div", { class: "sp-title" }, script.title || state.title),
el("div", { class: "sp-meta" },
`script · ${fmtDuration(script.total_duration_seconds)} · ${(script.sections || []).length} sections`),
...scriptSections(script, 0),
el("div", { class: "sp-fade" }, "END"),
)),
);
modal.classList.add("open");
}
function closeModal() { modal.classList.remove("open"); }
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); });
modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); });
// ---------------------------------------------------------------------------
// right rail: decisions, activity
// ---------------------------------------------------------------------------
function renderDecisions(s) {
const log = s.artifacts.decision_log;
const decisions = (log && log.decisions) || [];
if (!decisions.length) return null;
const body = el("div", { class: "panel-body" });
for (const d of decisions.slice(-8).reverse()) {
const alts = (d.options_considered || [])
.filter((o) => (o.option_id || o.label) !== d.selected && o.rejected_because !== undefined || (o.option_id !== d.selected && (o.option_id || o.label)))
.filter((o) => o.option_id !== d.selected);
body.append(el("div", { class: "decision" },
el("div", { class: "d-cat" }, `${d.category || "decision"}${d.confidence ? ` · ${d.confidence}` : ""}`),
el("div", { class: "d-pick" }, `${d.subject || ""} `, el("span", { class: "arrow" }, "→"), ` ${d.selected || ""}`),
d.reason ? el("div", { class: "d-why" }, d.reason) : null,
alts.length ? el("div", { class: "d-alt" }, "also considered: ",
alts.slice(0, 3).map((o, i) => [i ? " · " : "", el("s", {}, o.label || o.option_id)]).flat()) : null,
));
}
return el("div", { class: "panel" },
el("div", { class: "panel-head" }, el("h2", {}, "Decisions"), el("span", { class: "meta" }, "decision_log.json")),
body);
}
function renderActivity(s) {
const events = s.events || [];
if (!events.length) return null;
const body = el("div", { class: "panel-body" });
const started = new Map();
for (const ev of events) {
if (ev.event === "start") started.set(`${ev.tool}:${ev.scene_id || ""}`, ev);
}
for (const ev of events.slice(-10).reverse()) {
let statusEl;
if (ev.event === "finish") {
statusEl = el("span", { class: "status ok" },
`${ev.duration_s != null ? ` ${ev.duration_s}s` : ""}`);
} else if (ev.event === "error") {
statusEl = el("span", { class: "status err" }, "✕");
} else {
statusEl = el("span", { class: "status run" }, "● running");
}
body.append(el("div", { class: "act-row" },
el("span", { class: "t" }, fmtClock(ev.ts)),
el("span", { class: "tool" }, ev.tool || ""),
el("span", { class: "target" }, ev.scene_id || ""),
statusEl,
));
}
return el("div", { class: "panel" },
el("div", { class: "panel-head" }, el("h2", {}, "Activity"), el("span", { class: "meta" }, "events.jsonl")),
body);
}
// ---------------------------------------------------------------------------
// storyboard filmstrip
// ---------------------------------------------------------------------------
function sceneCard(s, card) {
const dur = card.duration_seconds;
const width = Math.max(132, Math.min(300, 70 + (dur || 3) * 26));
const wrap = el("div", { class: "scene-card", style: `width:${width}px` });
const slate = el("div", { class: "sc-slate" },
el("span", { class: "num" }, `SC ${String(card.id).replace(/^sc/i, "").padStart(2, "0")}`),
card.takes.length > 1 ? el("span", { class: "take" }, `T${card.takes.length}`) : null,
card.hero_moment ? el("span", { class: "hero" }, "★ HERO") : null,
el("span", { class: "dur" }, fmtDuration(dur)),
);
wrap.append(slate);
// visual slot
let thumb;
if (card.generating) {
thumb = el("div", { class: "thumb generating" },
el("div", { class: "shimmer" }),
el("div", { class: "gen-label" },
el("span", {}, "◉ GENERATING"),
el("span", { class: "sub" }, card.generating_tool || "")));
} else if (card.visual && card.visual.exists) {
const v = card.visual;
const badge = [v.model || v.source_tool, v.cost_usd != null ? fmtMoney(v.cost_usd) : null,
v.quality_score != null ? `q ${v.quality_score}` : null].filter(Boolean).join(" · ");
if (v.type === "video") {
thumb = el("div", { class: "thumb approved" },
el("video", { src: mediaURL(s.project_id, v.path), muted: "", preload: "metadata", playsinline: "" }),
el("span", { class: "play" }, "▶"),
badge ? el("span", { class: "badge" }, badge) : null);
thumb.onclick = () => {
const vid = thumb.querySelector("video");
if (vid.paused) vid.play(); else vid.pause();
};
} else {
thumb = el("div", { class: "thumb approved" },
el("img", { src: thumbURL(s.project_id, v.path, 640), loading: "lazy", alt: "" }),
badge ? el("span", { class: "badge" }, badge) : null);
}
} else if (card.visual && !card.visual.exists) {
thumb = el("div", { class: "thumb missing" },
el("div", { class: "spec-in" },
el("span", { class: "warn-ic" }, "⚑"),
el("div", { class: "spec-desc" }, "asset in manifest, file missing"),
el("div", { class: "spec-shot" }, card.visual.path || "")));
} else if (card.type === "text_card") {
thumb = el("div", { class: "thumb textcard" },
el("div", { class: "tc-copy" }, (card.narration || card.description || "").slice(0, 48)));
} else if (card.required_assets.length) {
thumb = el("div", { class: "thumb missing" },
el("div", { class: "spec-in" },
el("span", { class: "warn-ic" }, "⚑"),
el("div", { class: "spec-desc" }, "no asset yet"),
el("div", { class: "spec-shot" }, (card.required_assets[0].description || "").slice(0, 60))));
} else {
thumb = el("div", { class: "thumb spec" },
el("div", { class: "spec-in" },
el("div", { class: "spec-desc" }, card.description || ""),
el("div", { class: "spec-shot" }, [card.framing, card.movement].filter(Boolean).join(" · ").slice(0, 70))));
}
wrap.append(thumb);
// shot language chips
const sl = card.shot_language;
if (sl) {
wrap.append(el("div", { class: "shotchips", style: "display:flex;flex-wrap:wrap;gap:4px;padding:7px 2px 0" },
[sl.shot_size, sl.camera_movement, sl.lens_mm ? `${sl.lens_mm}mm` : null, sl.lighting_key]
.filter(Boolean)
.map((t) => el("span", { style: "font-family:var(--mono);font-size:8.5px;letter-spacing:.04em;color:#62626c;border:1px solid #212129;border-radius:3px;padding:1px 5px" }, String(t).replaceAll("_", " ")))));
}
// takes drawer
if (card.takes.length > 1) {
const takes = el("div", { class: "takes" });
card.takes.forEach((t, i) => {
const isActive = t === card.visual;
const tk = el("span", { class: `tk${isActive ? " active" : ""}`, title: `take ${i + 1}` });
if (t.exists && t.type === "image") tk.append(el("img", { src: thumbURL(s.project_id, t.path, 320), loading: "lazy", alt: "" }));
takes.append(tk);
});
takes.append(el("span", { class: "tk-label" }, `${card.takes.length} TAKES`));
wrap.append(takes);
}
// narration + audio
if (card.narration) {
wrap.append(el("div", { class: "narr" }, card.narration));
} else if (card.shot_intent || card.description) {
wrap.append(el("div", { class: "narr tc-note" }, (card.shot_intent || card.description || "").slice(0, 110)));
}
const narrAudio = card.audio.find((a) => a.exists && (a.type === "narration" || a.type === "audio"));
if (narrAudio) {
const wave = el("div", { class: "wave", style: "cursor:pointer", title: "Play narration" });
waveBars(wave, card.id + narrAudio.path);
wave.append(el("span", { class: "wv-time" }, narrAudio.duration_seconds ? fmtDuration(narrAudio.duration_seconds) : "♪"));
wave.onclick = () => {
player.src = mediaURL(s.project_id, narrAudio.path);
player.play();
};
wrap.append(wave);
}
return wrap;
}
function renderStoryboard(s) {
const board = s.storyboard;
if (!board) return null;
const strip = el("div", { class: "filmstrip" });
for (const card of board.scenes) strip.append(sceneCard(s, card));
return el("div", {},
el("div", { class: "section-title" }, "Storyboard",
el("span", { class: "meta" },
`${board.scenes.length} scenes${board.total_duration_seconds ? ` · ${fmtDuration(board.total_duration_seconds)}` : ""} · card width ∝ duration`)),
el("div", { class: "strip-outer" }, strip));
}
// ---------------------------------------------------------------------------
// renders + degraded media
// ---------------------------------------------------------------------------
function renderRenders(s) {
const renders = s.media.renders;
if (!renders.length) return null;
if (activeRender >= renders.length) activeRender = 0;
const current = renders[activeRender];
const video = el("video", { src: mediaURL(s.project_id, current.path), controls: "", preload: "none" });
const versions = el("div", { class: "render-meta" },
renders.map((r, i) => el("span", {
class: `v${i === activeRender ? " active" : ""}`,
onclick: () => { activeRender = i; render(); },
}, `${r.path.split("/").pop()}${r.at_root ? " · root" : ""}`)),
el("span", { style: "margin-left:auto" }, `${(current.size / 1048576).toFixed(1)} MB`),
);
return el("div", {},
el("div", { class: "section-title" }, "Renders",
el("span", { class: "meta" }, `${renders.length} version${renders.length === 1 ? "" : "s"}`)),
el("div", { class: "render-hero" }, video),
versions);
}
function renderFoundMedia(s) {
// Degraded view: show discovered snapshots when there's no storyboard.
if (s.storyboard || !s.media.snapshots.length) return null;
const grid = el("div", { class: "found-grid" });
for (const snap of s.media.snapshots.slice(0, 12)) {
grid.append(el("div", { class: "thumb" },
el("img", { src: thumbURL(s.project_id, snap.path, 640), loading: "lazy", alt: "" })));
}
return el("div", {},
el("div", { class: "section-title" }, "What the watcher found",
el("span", { class: "meta" }, "snapshots / verification frames")),
grid);
}
function renderNoState(s) {
if (s.has_pipeline_state) return null;
return el("div", { class: "notice", style: "border-color:#2b2b33;background:var(--surface-2);color:var(--text-3)" },
el("span", { style: "font-size:15px" }, "◌"),
el("span", {},
el("b", { style: "color:var(--text-2)" }, "No pipeline state. "),
"This project has no checkpoints — Backlot is showing what it found on disk. ",
"Runs that follow the checkpoint protocol get the full board."));
}
function renderAwaitingNotice(s) {
const awaiting = s.stages.find((x) => x.status === "awaiting_human");
if (!awaiting) return null;
return el("div", { class: "notice" },
el("span", { style: "font-size:16px" }, "◈"),
el("span", {},
el("b", {}, `The ${awaiting.name} stage is waiting for your review. `),
"The agent is paused at this gate — reply ", el("b", {}, "in chat"), " to approve or request changes."));
}
// ---------------------------------------------------------------------------
// page assembly
// ---------------------------------------------------------------------------
function render() {
if (!state) return;
const s = state;
document.title = `Backlot — ${s.title}`;
app.innerHTML = "";
app.append(renderSlate(s));
app.append(renderRail(s));
const drawer = renderDrawer(s);
if (drawer) app.append(drawer);
const awaitingNotice = renderAwaitingNotice(s);
if (awaitingNotice) app.append(awaitingNotice);
const noState = renderNoState(s);
if (noState) app.append(noState);
const main = el("div", { class: "main-col" });
const script = renderScriptCard(s);
if (script) main.append(script);
const aside = el("aside", {});
const decisions = renderDecisions(s);
const activity = renderActivity(s);
if (decisions) aside.append(decisions);
if (activity) aside.append(activity);
if (script || decisions || activity) {
app.append(el("div", { class: "board" }, main, aside));
}
const storyboard = renderStoryboard(s);
if (storyboard) app.append(storyboard);
const found = renderFoundMedia(s);
if (found) app.append(found);
const renders = renderRenders(s);
if (renders) app.append(renders);
}
async function refresh() {
state = await getJSON(`/api/project/${encodeURIComponent(projectId)}/state`);
render();
}
refresh().catch((err) => {
app.innerHTML = "";
app.append(el("div", { class: "empty", style: "margin-top:80px" },
el("div", { class: "big" }, "PROJECT NOT FOUND"),
el("div", {}, String(err))));
});
// ?static=1 disables the live feed (screenshots, static exports).
if (!new URLSearchParams(location.search).has("static")) {
subscribe(`/api/project/${encodeURIComponent(projectId)}/events`, () => refresh().catch(console.error));
}

26
backlot/ui/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backlot — Library</title>
<link rel="stylesheet" href="/ui/board.css">
</head>
<body>
<div class="wrap">
<header class="slate">
<div class="clapper"></div>
<div>
<span class="wordmark">Backlot</span>
<h1>Library</h1>
</div>
<span class="chip" id="count"></span>
<div class="spacer"></div>
<span class="live idle" id="liveBadge"><span class="dot"></span><span id="liveText">IDLE</span></span>
</header>
<div class="lib-grid" id="grid"></div>
<p class="hint" id="empty" style="display:none">No projects yet — run a production and it will appear here.</p>
</div>
<script type="module" src="/ui/library.js"></script>
</body>
</html>

102
backlot/ui/lib.js Normal file
View File

@@ -0,0 +1,102 @@
// Shared helpers for the Backlot UI.
export async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${url}`);
return res.json();
}
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
if (k === "class") node.className = v;
else if (k === "html") node.innerHTML = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const child of children.flat()) {
if (child == null) continue;
node.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return node;
}
export function fmtDuration(seconds) {
if (seconds == null) return "";
const s = Math.round(seconds);
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, "0")}`;
}
export function fmtMoney(v) {
if (v == null) return "—";
return `$${Number(v).toFixed(2)}`;
}
export function fmtAgo(epochSeconds) {
if (!epochSeconds) return "";
const diff = Date.now() / 1000 - epochSeconds;
if (diff < 90) return "just now";
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
return `${Math.round(diff / 86400)}d ago`;
}
export function fmtClock(iso) {
if (!iso) return "";
try {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return "";
}
}
export function mediaURL(projectId, relPath) {
return `/media/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}`;
}
// Downscaled cached JPEG for images (full media only in players/lightbox).
export function thumbURL(projectId, relPath, w = 640) {
return `/thumb/${encodeURIComponent(projectId)}/${relPath.split("/").map(encodeURIComponent).join("/")}?w=${w}`;
}
// Subscribe to a server-sent change feed; call onChange (debounced) per burst.
export function subscribe(url, onChange) {
let timer = null;
const source = new EventSource(url);
source.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data.type !== "change") return;
} catch {
return;
}
clearTimeout(timer);
timer = setTimeout(onChange, 250);
};
source.onerror = () => { /* EventSource auto-reconnects */ };
return source;
}
// Deterministic pseudo-waveform bars (seeded by a string).
export function waveBars(container, seedStr, count = 26, maxH = 14) {
let seed = 0;
for (const c of seedStr || "wave") seed = (seed * 31 + c.charCodeAt(0)) % 2147483647;
seed = seed || 7;
container.innerHTML = "";
for (let i = 0; i < count; i++) {
seed = (seed * 16807) % 2147483647;
const h = 3 + ((seed % 100) / 100) * maxH * (0.55 + 0.45 * Math.sin(i / 5));
const bar = document.createElement("i");
bar.style.height = `${Math.max(3, h)}px`;
container.append(bar);
}
}
export const STAGE_ICONS = {
completed: "✓",
in_progress: "◉",
awaiting_human: "◈",
failed: "✕",
};

63
backlot/ui/library.js Normal file
View File

@@ -0,0 +1,63 @@
import { el, fmtAgo, getJSON, subscribe, thumbURL } from "/ui/lib.js";
const grid = document.getElementById("grid");
function miniRail(states) {
const rail = el("div", { class: "mini-rail" });
for (const s of states) {
const cls = s.status === "completed" ? "d"
: s.status === "in_progress" ? "a"
: s.status === "awaiting_human" ? "w" : "";
rail.append(el("i", { class: cls, title: `${s.name}: ${s.status}` }));
}
return rail;
}
function card(p) {
const poster = el("div", { class: "lib-poster" });
if (p.poster) {
poster.append(el("img", { src: thumbURL(p.project_id, p.poster, 640), loading: "lazy", alt: "" }));
} else {
poster.append(el("span", { class: "lp-txt" }, "NO MEDIA YET"));
}
if (p.live && p.active_stage) {
poster.append(el("span", { class: "lp-live" },
el("span", { class: "dot" }),
p.awaiting_human ? "◈ AWAITING YOU" : `LIVE · ${p.active_stage.toUpperCase()}`));
} else if (p.awaiting_human) {
poster.append(el("span", { class: "lp-live" }, "◈ AWAITING YOU"));
}
const meta = el("div", { class: "lb-meta" },
el("span", { class: "chip" }, p.pipeline_type || "unknown"),
p.scene_count ? el("span", { class: "chip" }, `${p.scene_count} scenes`) : null,
p.render_count ? el("span", { class: "chip" }, `${p.render_count} renders`) : null,
el("span", { class: "when" }, fmtAgo(p.last_activity)),
);
return el("a", { class: `lib-card${p.live ? " live-card" : ""}`, href: `/p/${p.project_id}`, style: "text-decoration:none;color:inherit" },
poster,
el("div", { class: "lib-body" },
el("h3", {}, (p.title || p.project_id).toUpperCase()),
meta,
p.stage_states.length ? miniRail(p.stage_states) : null,
),
);
}
async function render() {
const projects = await getJSON("/api/projects");
document.getElementById("count").textContent = `${projects.length} projects`;
const liveCount = projects.filter((p) => p.live).length;
const badge = document.getElementById("liveBadge");
badge.classList.toggle("idle", liveCount === 0);
document.getElementById("liveText").textContent = liveCount ? `${liveCount} LIVE` : "IDLE";
grid.innerHTML = "";
document.getElementById("empty").style.display = projects.length ? "none" : "block";
for (const p of projects) grid.append(card(p));
}
render().catch(console.error);
if (!new URLSearchParams(location.search).has("static")) {
subscribe("/api/library/events", () => render().catch(console.error));
}