From 64e44612d945e3399986849a1658d292c426fb76 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 1 Jul 2026 23:57:06 -0700 Subject: [PATCH] backlot phase 3: in-browser replay, live-run fixes, simulation driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backlot/README.md | 42 +++++++ backlot/state.py | 5 + backlot/ui/board.css | 6 + backlot/ui/board.js | 190 +++++++++++++++++++++++++++++--- scripts/backlot_simulate_run.py | 161 +++++++++++++++++++++++++++ 5 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 backlot/README.md create mode 100644 scripts/backlot_simulate_run.py diff --git a/backlot/README.md b/backlot/README.md new file mode 100644 index 00000000..6ce7edcb --- /dev/null +++ b/backlot/README.md @@ -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//`. + +```bash +python -m backlot open # 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/.yaml` | +| stage states, gates, versions | `checkpoint_.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`. diff --git a/backlot/state.py b/backlot/state.py index 50e765ea..a4dec019 100644 --- a/backlot/state.py +++ b/backlot/state.py @@ -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. diff --git a/backlot/ui/board.css b/backlot/ui/board.css index 42c001a6..50bb4fca 100644 --- a/backlot/ui/board.css +++ b/backlot/ui/board.css @@ -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; } diff --git a/backlot/ui/board.js b/backlot/ui/board.js index 07cba410..0b9101c0 100644 --- a/backlot/ui/board.js +++ b/backlot/ui/board.js @@ -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); diff --git a/scripts/backlot_simulate_run.py b/scripts/backlot_simulate_run.py new file mode 100644 index 00000000..bec5b4e4 --- /dev/null +++ b/scripts/backlot_simulate_run.py @@ -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())