mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-23 16:46:47 +08:00
- 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)
765 lines
31 KiB
JavaScript
765 lines
31 KiB
JavaScript
// 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));
|
||
}
|