backlot phase 3: in-browser replay, live-run fixes, simulation driver

- replay mode: scrub a completed run from checkpoint history + event
  timestamps — stage rail rewinds, script/storyboard/renders appear at
  their real moments, generating shimmer replays; ~20s full-run playback
- stage history_entries exposed in BoardState (powers replay + versions)
- entrance choreography plays on first paint only (not on SSE refreshes)
- live-run fixes found by driving a simulated production against the
  board: null rendered by native append, activity ticker showing closed
  starts as running, scene-id label handling for 'scene-N' ids
- scripts/backlot_simulate_run.py: drives a fake production through the
  REAL contract (init_project, in_progress heartbeats, gated
  awaiting_human -> approved, per-scene events, growing manifest) —
  live-board verification + demo driver
- backlot/README.md
This commit is contained in:
calesthio
2026-07-01 23:57:06 -07:00
parent 4b4d7b1e99
commit 64e44612d9
5 changed files with 387 additions and 17 deletions

42
backlot/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Backlot — the living storyboard
A read-only local board that shows a production happening: pipeline stages
lighting up, the script as a screenplay page, the scene plan as a filmstrip
that fills in as assets generate, decisions, spend, and activity — all
derived from what the pipeline already writes to `projects/<id>/`.
```bash
python -m backlot open <project-id> # start server if needed + open browser
python -m backlot open # library view (all projects)
python -m backlot serve --port 4750 # run the server in the foreground
```
## How it stays live
No agent involvement. A `watchfiles` watcher on `projects/` publishes change
notifications over SSE; the browser refetches board state. State sources:
| Board element | Disk source |
|---|---|
| identity / rail order | `project.json` + `pipeline_defs/<type>.yaml` |
| stage states, gates, versions | `checkpoint_<stage>.json` + `history/` |
| script card / modal | `artifacts/script.json` |
| filmstrip cards | `scene_plan × script × asset_manifest` join |
| generating shimmer, activity | `events.jsonl` (written by `BaseTool` instrumentation) |
| cost meter | checkpoint `cost_snapshot` |
| renders | `renders/*.mp4` (+ root-level mp4 heuristic) |
Projects without checkpoints degrade gracefully to a "what the watcher
found" view — media, snapshots, renders.
**Replay**: a completed run can be scrubbed end-to-end (▶ REPLAY RUN on the
board) — reconstructed from checkpoint history and event timestamps.
Try it without a real production:
```bash
python scripts/backlot_simulate_run.py # live demo run (~1 min)
python -m backlot open backlot-demo-run
```
Design doc: `internal/design/LIVING_STORYBOARD.md`.

View File

@@ -147,6 +147,11 @@ def _build_stage_rail(
"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),
# Chronological status trail (history + current) — powers replay.
"history_entries": (
[{"status": v.get("status"), "timestamp": v.get("timestamp")} for v in versions]
+ ([{"status": cp.get("status"), "timestamp": cp.get("timestamp")}] if cp else [])
),
}
# Gate audit: a gated stage that completed without ever passing
# through awaiting_human (current or archived) was gate-skipped.

View File

@@ -514,3 +514,9 @@ body.replaying .live .dot { background: var(--blue); animation: none; }
.hint { font-size: 12px; color: var(--text-3); padding: 10px 2px; }
a { color: inherit; }
/* entrance choreography plays only on first paint, not on every SSE refresh */
body:not(.first) .slate, body:not(.first) .rail .stage,
body:not(.first) .script-card, body:not(.first) .notice,
body:not(.first) aside .panel, body:not(.first) .scene-card,
body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }

View File

@@ -13,6 +13,8 @@ const player = document.getElementById("player");
let state = null;
let selectedStage = null; // stage drawer open for this stage name
let activeRender = 0;
let replay = null; // {t0, t1, t, playing} — replay mode when non-null
let firstPaint = true;
// ---------------------------------------------------------------------------
// header slate
@@ -46,16 +48,14 @@ function renderSlate(s) {
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"),
);
cost.append(el("div", { class: "nums" }, el("b", {}, fmtMoney(spent)),
hasBudget ? el("span", {}, ` / ${fmtMoney(budget)}`) : ""));
if (hasBudget) {
cost.append(el("div", { class: "bar" }, el("i", {
class: pct > 75 ? "warn" : "", style: `width:${pct}%`,
})));
}
cost.append(el("div", { class: "label" }, "generation spend"));
}
return el("header", { class: "slate" },
@@ -271,15 +271,27 @@ function renderActivity(s) {
const events = s.events || [];
if (!events.length) return null;
const body = el("div", { class: "panel-body" });
const started = new Map();
// A start is "running" only until a later finish/error for the same
// tool+scene closes it — closed starts are dropped (the finish row tells
// the story), unmatched starts render as live.
const open = new Map();
const rows = [];
for (const ev of events) {
if (ev.event === "start") started.set(`${ev.tool}:${ev.scene_id || ""}`, ev);
const key = `${ev.tool}:${ev.scene_id || ""}`;
if (ev.event === "start") {
open.set(key, ev);
} else {
open.delete(key);
rows.push(ev);
}
}
for (const ev of events.slice(-10).reverse()) {
rows.push(...open.values());
rows.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
for (const ev of rows.slice(-10).reverse()) {
let statusEl;
if (ev.event === "finish") {
statusEl = el("span", { class: "status ok" },
`${ev.duration_s != null ? ` ${ev.duration_s}s` : ""}`);
statusEl = el("span", { class: `status ${ev.success === false ? "err" : "ok"}` },
`${ev.success === false ? "✕" : "✓"}${ev.duration_s != null ? ` ${ev.duration_s.toFixed ? ev.duration_s.toFixed(1) : ev.duration_s}s` : ""}${ev.cost_usd ? ` ${fmtMoney(ev.cost_usd)}` : ""}`);
} else if (ev.event === "error") {
statusEl = el("span", { class: "status err" }, "✕");
} else {
@@ -301,13 +313,20 @@ function renderActivity(s) {
// storyboard filmstrip
// ---------------------------------------------------------------------------
function sceneLabel(id) {
// "sc4" → "SC 04", "scene-11" → "SC 11", anything else → uppercased id
const m = String(id).match(/(\d+)\s*$/);
if (m) return `SC ${m[1].padStart(2, "0")}`;
return String(id).toUpperCase().slice(0, 10);
}
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")}`),
el("span", { class: "num" }, sceneLabel(card.id)),
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)),
@@ -475,17 +494,154 @@ function renderAwaitingNotice(s) {
"The agent is paused at this gate — reply ", el("b", {}, "in chat"), " to approve or request changes."));
}
// ---------------------------------------------------------------------------
// replay — scrub a completed run from its timestamps
// ---------------------------------------------------------------------------
const ts = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
function replayBounds(s) {
const moments = [];
for (const st of s.stages) {
for (const h of st.history_entries || []) {
const t = ts(h.timestamp);
if (t) moments.push(t);
}
}
for (const ev of s.events || []) {
const t = ts(ev.ts);
if (t) moments.push(t);
}
if (moments.length < 2) return null;
return { t0: Math.min(...moments), t1: Math.max(...moments) };
}
function stateAt(s, T) {
const view = structuredClone(s);
for (const st of view.stages) {
const past = (st.history_entries || []).filter((h) => ts(h.timestamp) != null && ts(h.timestamp) <= T);
if (!past.length) {
st.status = "pending"; st.review = null; st.timestamp = null;
st.gate_skipped = false; st.partial_progress = null;
} else {
const cur = past[past.length - 1];
st.status = cur.status || "pending";
st.timestamp = cur.timestamp;
}
}
view.events = (view.events || []).filter((ev) => ts(ev.ts) != null && ts(ev.ts) <= T);
// Storyboard: visuals appear as their scene finishes (events) or when the
// assets stage has completed as of T (legacy runs without events).
if (view.storyboard) {
const assetsStage = view.stages.find((x) => x.name === "assets");
const assetsDone = assetsStage && assetsStage.status === "completed";
const finished = new Set();
const startedNow = new Map();
for (const ev of view.events) {
if (!ev.scene_id) continue;
if (ev.event === "finish") { finished.add(ev.scene_id); startedNow.delete(ev.scene_id); }
else if (ev.event === "start") startedNow.set(ev.scene_id, ev);
else if (ev.event === "error") startedNow.delete(ev.scene_id);
}
const scenePlanStage = view.stages.find((x) => x.name === "scene_plan");
const scenePlanDone = scenePlanStage && ["completed", "awaiting_human"].includes(scenePlanStage.status);
if (!scenePlanDone) {
view.storyboard = null;
} else {
for (const card of view.storyboard.scenes) {
const visible = assetsDone || finished.has(card.id);
if (!visible) { card.visual = null; card.takes = []; card.audio = []; }
card.generating = startedNow.has(card.id);
card.generating_tool = (startedNow.get(card.id) || {}).tool;
}
}
const scriptStage = view.stages.find((x) => x.name === "script");
if (!(scriptStage && ["completed", "awaiting_human"].includes(scriptStage.status))) {
delete view.artifacts.script;
}
const composeStage = view.stages.find((x) => x.name === "compose");
if (!(composeStage && composeStage.status === "completed")) {
view.media.renders = [];
}
}
return view;
}
function renderReplayBar(s) {
const bounds = replayBounds(s);
if (!bounds) return null;
if (!replay) {
// collapsed: just the entry button
return el("div", { class: "replay-bar", style: "justify-content:flex-end" },
el("span", { class: "rp-time" }, "scrub the whole run"),
el("span", { class: "rp-btn", onclick: startReplay }, "▶ REPLAY RUN"));
}
const pos = (replay.t - replay.t0) / Math.max(1, replay.t1 - replay.t0);
return el("div", { class: "replay-bar" },
el("span", { class: "rp-btn", onclick: toggleReplayPlay }, replay.playing ? "❚❚" : "▶"),
el("input", {
type: "range", min: "0", max: "1000", value: String(Math.round(pos * 1000)),
oninput: (e) => {
replay.t = replay.t0 + (Number(e.target.value) / 1000) * (replay.t1 - replay.t0);
replay.playing = false;
render();
},
}),
el("span", { class: "rp-time" },
new Date(replay.t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })),
el("span", { class: "rp-btn", onclick: stopReplay }, "✕ LIVE"),
);
}
function startReplay() {
const bounds = replayBounds(state);
if (!bounds) return;
replay = { ...bounds, t: bounds.t0, playing: true };
document.body.classList.add("replaying");
tickReplay();
render();
}
function stopReplay() {
replay = null;
document.body.classList.remove("replaying");
render();
}
function toggleReplayPlay() {
if (!replay) return;
replay.playing = !replay.playing;
if (replay.playing) tickReplay();
render();
}
function tickReplay() {
if (!replay || !replay.playing) return;
// A full run replays in ~20 seconds regardless of real duration
// (10 renders/second — full re-render per tick, keep it modest).
const step = (replay.t1 - replay.t0) / 200;
replay.t = Math.min(replay.t1, replay.t + step);
if (replay.t >= replay.t1) replay.playing = false;
render();
if (replay.playing) setTimeout(tickReplay, 100);
}
// ---------------------------------------------------------------------------
// page assembly
// ---------------------------------------------------------------------------
function render() {
if (!state) return;
const s = state;
const s = replay ? stateAt(state, replay.t) : state;
document.title = `Backlot — ${s.title}`;
document.body.classList.toggle("first", firstPaint);
firstPaint = false;
app.innerHTML = "";
app.append(renderSlate(s));
app.append(renderRail(s));
const replayBar = renderReplayBar(state);
if (replayBar) app.append(replayBar);
const drawer = renderDrawer(s);
if (drawer) app.append(drawer);
const awaitingNotice = renderAwaitingNotice(s);

View File

@@ -0,0 +1,161 @@
"""Simulate a pipeline run on disk to exercise the Backlot live board.
Drives a fake production through the REAL contract — init_project,
in_progress checkpoints, gated awaiting_human states, tool events,
progressively-written artifacts — so the board can be watched updating live.
Also useful as a demo driver.
python scripts/backlot_simulate_run.py [--project backlot-demo-run]
[--fast] [--cleanup]
--fast compresses waits to ~0.3s (for automated verification)
--cleanup removes the project directory at the end
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lib.checkpoint import PROJECTS_DIR, init_project, write_checkpoint
from lib.events import emit_event
SCENES = [
("sc1", "Opening — a lighthouse at dusk", 0, 4, "The coast holds its breath."),
("sc2", "The beam sweeps the water", 4, 9, "Every night, the same promise."),
("sc3", "A storm builds offshore", 9, 15, "Until the night the light went out."),
("sc4", "The keeper climbs the stairs", 15, 21, "Someone still has to climb."),
]
def artifacts_for(project_id: str) -> dict:
script = {
"version": "1.0",
"title": "The Last Lighthouse",
"total_duration_seconds": 21,
"sections": [
{"id": f"s{i+1}", "label": desc.split("")[0].strip(), "text": narration,
"start_seconds": s0, "end_seconds": s1}
for i, (sid, desc, s0, s1, narration) in enumerate(SCENES)
],
}
scene_plan = {
"version": "1.0",
"scenes": [
{"id": sid, "type": "generated", "description": desc,
"start_seconds": s0, "end_seconds": s1,
"script_section_id": f"s{i+1}",
"hero_moment": sid == "sc3",
"required_assets": [{"type": "image", "description": desc, "source": "generate"}]}
for i, (sid, desc, s0, s1, _n) in enumerate(SCENES)
],
}
return {"script": script, "scene_plan": scene_plan}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project", default="backlot-demo-run")
parser.add_argument("--fast", action="store_true")
parser.add_argument("--cleanup", action="store_true")
args = parser.parse_args()
wait = 0.3 if args.fast else 2.5
pid = args.project
pdir = PROJECTS_DIR / pid
if pdir.exists():
shutil.rmtree(pdir)
print(f"[sim] init_project {pid}")
init_project(pid, title="The Last Lighthouse", pipeline_type="cinematic",
style_playbook="clean-professional")
art = artifacts_for(pid)
def save_artifact(name: str, data: dict) -> None:
path = pdir / "artifacts" / f"{name}.json"
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def cp(stage: str, status: str, artifacts: dict, **kw) -> None:
write_checkpoint(PROJECTS_DIR, pid, stage, status, artifacts,
pipeline_type="cinematic", **kw)
print(f"[sim] checkpoint {stage} -> {status}")
time.sleep(wait)
# research auto-proceeds (schema-valid fixture from the contract tests)
cp("research", "in_progress", {})
from tests.contracts.test_phase0_contracts import sample_artifact
brief = sample_artifact("research_brief")
brief["topic"] = "The Last Lighthouse"
cp("research", "completed", {"research_brief": brief})
# script gates: awaiting_human -> approved
cp("script", "in_progress", {})
save_artifact("script", art["script"])
cp("script", "awaiting_human", {"script": art["script"]},
review={"round": 1, "decision": "pass", "critical": 0, "suggestions": 1,
"nitpicks": 0, "summary": "Hook is strong; tightened s3."})
time.sleep(wait) # "user reads the script on the board"
cp("script", "completed", {"script": art["script"]}, human_approved=True)
# scene_plan gates too
cp("scene_plan", "in_progress", {})
save_artifact("scene_plan", art["scene_plan"])
cp("scene_plan", "awaiting_human", {"scene_plan": art["scene_plan"]})
time.sleep(wait)
cp("scene_plan", "completed", {"scene_plan": art["scene_plan"]}, human_approved=True)
# assets: per-scene tool events + growing manifest + partial progress
cp("assets", "in_progress", {})
manifest = {"version": "1.0", "assets": [], "total_cost_usd": 0.0}
done_ids = []
from PIL import Image, ImageDraw
palette = [(24, 32, 48), (40, 30, 60), (60, 24, 24), (20, 48, 40)]
for i, (sid, desc, _s0, _s1, _n) in enumerate(SCENES):
emit_event(pdir, {"tool": "flux_image", "event": "start", "scene_id": sid})
print(f"[sim] generating {sid}")
time.sleep(wait * 1.5)
rel = f"assets/images/{sid}.png"
img = Image.new("RGB", (640, 360), palette[i % 4])
draw = ImageDraw.Draw(img)
draw.text((20, 160), f"{sid}{desc[:40]}", fill=(230, 225, 210))
img.save(pdir / rel)
emit_event(pdir, {"tool": "flux_image", "event": "finish", "scene_id": sid,
"success": True, "cost_usd": 0.05, "duration_s": wait * 1.5,
"output_path": rel})
manifest["assets"].append({
"id": f"img_{sid}", "type": "image", "path": rel, "scene_id": sid,
"source_tool": "flux_image", "model": "flux-sim", "cost_usd": 0.05,
"prompt": desc, "quality_score": 0.88,
})
manifest["total_cost_usd"] = round(manifest["total_cost_usd"] + 0.05, 2)
save_artifact("asset_manifest", manifest)
done_ids.append(sid)
write_checkpoint(PROJECTS_DIR, pid, "assets", "in_progress", {},
pipeline_type="cinematic",
metadata={"partial_progress": {"completed_scene_ids": done_ids}},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
# assets gate (the storyboard review)
cp("assets", "awaiting_human", {"asset_manifest": manifest},
cost_snapshot={"total_spent_usd": manifest["total_cost_usd"],
"total_reserved_usd": 0.0,
"budget_remaining_usd": 5 - manifest["total_cost_usd"]})
time.sleep(wait)
cp("assets", "completed", {"asset_manifest": manifest}, human_approved=True)
print(f"[sim] done — board at http://127.0.0.1:4750/p/{pid}")
if args.cleanup:
shutil.rmtree(pdir)
print("[sim] cleaned up")
return 0
if __name__ == "__main__":
raise SystemExit(main())