Files
OpenMontage/backlot/ui/board.js
calesthio 1d60f0da14 backlot: fix all five dogfood findings (F-01..F-05)
- F-01: cost bar crit (red) state past 90% of budget
- F-02: normalize() hardens fetched board state against sparse payloads
- F-03: /thumb 404s for videos with no extractable poster frame instead
  of serving raw video bytes
- F-04: checkpoint artifact path refs only resolve inside the project dir
- F-05 (board half): stall detection — in_progress stage with no disk
  activity >10min renders red 'stalled?' + header badge flips to STALLED?;
  verified against the real wedged why-cities-glow project
- eval harness from dogfood session committed (visual regression +
  interaction smoke, capture watcher, server/gate test suites) +
  regression tests for each finding; 46 backlot tests green, visual eval
  green (restage-before-capture note logged)
2026-07-02 08:22:03 -07:00

765 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
let replay = null; // {t0, t1, t, playing} — replay mode when non-null
let firstPaint = true;
// ---------------------------------------------------------------------------
// 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");
const stalled = s.stages.find((x) => x.stalled);
let liveEl;
if (awaiting) {
liveEl = el("span", { class: "live" }, el("span", { class: "dot" }), "◈ AWAITING YOU");
} else if (stalled) {
liveEl = el("span", { class: "live", style: "color:var(--red)" },
el("span", { class: "dot", style: "background:var(--red);animation:none" }), "⚠ STALLED?");
} 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)}`) : ""));
if (hasBudget) {
cost.append(el("div", { class: "bar" }, el("i", {
class: pct > 90 ? "crit" : pct > 75 ? "warn" : "", style: `width:${pct}%`,
})));
}
cost.append(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.stalled) {
return `stalled? no activity for ${st.stalled_minutes}m\nask the agent for status`;
}
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" ? (st.stalled ? "active stalled" : "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" : ""}${st.undeclared ? " undeclared" : ""}`,
title: st.undeclared ? `"${st.name}" ran but isn't declared by this pipeline's manifest` : null,
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" },
st.undeclared ? `${stageSub(st)}\nunlisted`.trim() : 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.option_id || o.label));
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" });
// 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. Counted (not keyed-single)
// so parallel runs of the same tool on the same scene stay visible.
const open = new Map(); // key -> {count, ev}
const rows = [];
for (const ev of events) {
const key = `${ev.tool}:${ev.scene_id || ""}`;
if (ev.event === "start") {
const slot = open.get(key) || { count: 0, ev };
slot.count += 1;
slot.ev = ev;
open.set(key, slot);
} else {
const slot = open.get(key);
if (slot) {
slot.count -= 1;
if (slot.count <= 0) open.delete(key);
}
rows.push(ev);
}
}
for (const slot of open.values()) rows.push(slot.ev);
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 ${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 {
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 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" }, 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)),
);
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];
// Full re-renders (every SSE refresh) must not reset an in-progress
// watch: carry playback position/state over to the recreated element.
const prev = document.querySelector(".render-hero video");
const src = mediaURL(s.project_id, current.path);
const video = el("video", { src, controls: "", preload: "none" });
if (prev && prev.getAttribute("src") === src && (prev.currentTime > 0 || !prev.paused)) {
const t = prev.currentTime;
const wasPlaying = !prev.paused && !prev.ended;
video.addEventListener("loadedmetadata", () => { video.currentTime = t; }, { once: true });
video.setAttribute("preload", "metadata");
if (wasPlaying) video.autoplay = true;
}
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."));
}
// ---------------------------------------------------------------------------
// replay — scrub a completed run from its timestamps
// ---------------------------------------------------------------------------
// Python writers emit tz-aware UTC isoformat, but treat tz-naive strings as
// UTC too — mixing local-parsed and UTC-parsed timestamps would skew replay
// ordering by the user's UTC offset.
const ts = (iso) => {
if (!iso) return null;
let s = String(iso);
if (!/(Z|[+-]\d{2}:?\d{2})$/.test(s)) s += "Z";
const t = Date.parse(s);
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;
}
}
}
// Final artifacts hide until their stage happened — for every project
// shape, storyboard or not (a degraded run must not show the finished
// movie before its stages ran).
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);
const timeLabel = el("span", { class: "rp-time" },
new Date(replay.t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
const setT = (value) => {
replay.t = replay.t0 + (Number(value) / 1000) * (replay.t1 - replay.t0);
timeLabel.textContent = new Date(replay.t)
.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
};
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)),
// A full render() would destroy this slider mid-drag: while dragging,
// only pause + track the time label; re-render the board on release.
onpointerdown: () => { replay.playing = false; },
oninput: (e) => setT(e.target.value),
onchange: (e) => { setT(e.target.value); render(); },
}),
timeLabel,
el("span", { class: "rp-btn", onclick: stopReplay }, "✕ LIVE"),
);
}
let replayTimer = null;
function startReplay() {
const bounds = replayBounds(state);
if (!bounds) return;
replay = { ...bounds, t: bounds.t0, playing: true };
document.body.classList.add("replaying");
scheduleTick();
render();
}
function stopReplay() {
replay = null;
clearTimeout(replayTimer);
document.body.classList.remove("replaying");
render();
}
function toggleReplayPlay() {
if (!replay) return;
replay.playing = !replay.playing;
if (replay.playing) scheduleTick();
render();
}
function scheduleTick() {
// Single pending tick, ever — rapid pause/play must not stack chains.
clearTimeout(replayTimer);
replayTimer = setTimeout(tickReplay, 100);
}
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) scheduleTick();
}
// ---------------------------------------------------------------------------
// page assembly
// ---------------------------------------------------------------------------
function render() {
if (!state) return;
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);
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);
}
// Defensive normalization (F-02): the server contract guarantees these
// fields, but a sparse/legacy payload must degrade, never crash the board.
function normalize(s) {
s.pipeline = s.pipeline || { pipeline_type: "unknown", stages: [], known: false };
s.stages = Array.isArray(s.stages) ? s.stages : [];
s.artifacts = s.artifacts || {};
s.media = s.media || {};
s.media.renders = Array.isArray(s.media.renders) ? s.media.renders : [];
s.media.snapshots = Array.isArray(s.media.snapshots) ? s.media.snapshots : [];
s.media.music = Array.isArray(s.media.music) ? s.media.music : [];
s.events = Array.isArray(s.events) ? s.events : [];
if (s.storyboard && Array.isArray(s.storyboard.scenes)) {
for (const c of s.storyboard.scenes) {
c.takes = Array.isArray(c.takes) ? c.takes : [];
c.audio = Array.isArray(c.audio) ? c.audio : [];
c.required_assets = Array.isArray(c.required_assets) ? c.required_assets : [];
}
} else {
s.storyboard = null;
}
return s;
}
async function refresh() {
state = normalize(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));
}