mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-24 00:56:45 +08:00
1143 lines
48 KiB
JavaScript
1143 lines
48 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 rawProjectPath = location.pathname.split("/p/")[1] || "";
|
||
const projectId = decodeURIComponent(rawProjectPath);
|
||
const encodedProjectId = encodeURIComponent(projectId);
|
||
const app = document.getElementById("app");
|
||
const modal = document.getElementById("modal");
|
||
const player = document.getElementById("player");
|
||
|
||
const THEME_KEY = "backlot.theme";
|
||
let currentTheme = localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark";
|
||
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;
|
||
|
||
function applyTheme(theme) {
|
||
currentTheme = theme === "light" ? "light" : "dark";
|
||
document.documentElement.dataset.theme = currentTheme;
|
||
localStorage.setItem(THEME_KEY, currentTheme);
|
||
}
|
||
|
||
function renderThemeToggle() {
|
||
const next = currentTheme === "light" ? "dark" : "light";
|
||
return el("button", {
|
||
class: "theme-toggle",
|
||
type: "button",
|
||
title: `Switch to ${next} theme`,
|
||
"aria-label": `Switch to ${next} theme`,
|
||
"aria-pressed": currentTheme === "light" ? "true" : "false",
|
||
onclick: () => {
|
||
applyTheme(next);
|
||
render();
|
||
},
|
||
}, el("span", { class: "theme-toggle-icon", "aria-hidden": "true" }, currentTheme === "light" ? "☾" : "☀"));
|
||
}
|
||
|
||
applyTheme(currentTheme);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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" }),
|
||
renderThemeToggle(),
|
||
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 artifactNamesForStage(st) {
|
||
const declared = Array.isArray(st.produces) ? st.produces : [];
|
||
const fallback = STAGE_ARTIFACTS[st.name] || [];
|
||
return [...new Set([...declared, ...fallback].filter(Boolean))];
|
||
}
|
||
|
||
function reviewMetrics(review) {
|
||
const nested = review && review.summary && typeof review.summary === "object"
|
||
? review.summary : {};
|
||
return {
|
||
critical: Number((review && review.critical) ?? nested.critical ?? 0),
|
||
suggestions: Number((review && review.suggestions) ?? nested.suggestions ?? 0),
|
||
nitpicks: Number((review && review.nitpicks) ?? nested.nitpicks ?? 0),
|
||
};
|
||
}
|
||
|
||
function reviewSummaryText(review) {
|
||
if (!review) return "";
|
||
if (typeof review.summary === "string") return review.summary;
|
||
const nested = review.summary && typeof review.summary === "object" ? review.summary : {};
|
||
const counts = reviewMetrics(review);
|
||
return [
|
||
review.decision,
|
||
`${counts.critical} critical`,
|
||
`${counts.suggestions} suggestion${counts.suggestions === 1 ? "" : "s"}`,
|
||
nested.review_focus_met ? `review focus ${nested.review_focus_met}` : null,
|
||
nested.schema_validation,
|
||
].filter(Boolean).join(" · ");
|
||
}
|
||
|
||
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) {
|
||
const metrics = reviewMetrics(st.review);
|
||
const summary = reviewSummaryText(st.review);
|
||
body.append(el("div", { class: "findings", style: "margin-bottom:12px" },
|
||
el("span", { class: `f ${metrics.critical ? "crit" : ""}` }, `${metrics.critical} critical`),
|
||
el("span", { class: `f ${metrics.suggestions ? "sugg" : ""}` }, `${metrics.suggestions} suggestions`),
|
||
el("span", { class: "f" }, `${metrics.nitpicks} nitpicks`),
|
||
summary ? el("span", { style: "font-size:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, summary) : null,
|
||
));
|
||
}
|
||
|
||
const names = artifactNamesForStage(st);
|
||
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:calc(9.5px * var(--fs-scale));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:calc(10.5px * var(--fs-scale));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 status = scriptStage ? scriptStage.status : "unknown";
|
||
const stamp = status === "completed"
|
||
? el("span", { class: "script-status script-approved" }, "APPROVED")
|
||
: status === "awaiting_human"
|
||
? el("span", { class: "script-status script-pending" }, "PENDING APPROVAL")
|
||
: status === "in_progress"
|
||
? el("span", { class: "script-status script-draft" }, "DRAFTING")
|
||
: null;
|
||
|
||
const card = el("div", { class: "script-card script-preview", title: "Click to expand full script", onclick: openScriptModal },
|
||
stamp,
|
||
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 humanize(value) {
|
||
return String(value || "artifact").replaceAll("_", " ");
|
||
}
|
||
|
||
function shortText(value, limit = 180) {
|
||
const text = String(value || "").trim();
|
||
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
||
}
|
||
|
||
function reviewFact(label, value) {
|
||
if (value == null || value === "") return null;
|
||
return el("div", { class: "approval-fact" },
|
||
el("span", {}, label),
|
||
el("b", {}, value),
|
||
);
|
||
}
|
||
|
||
function reviewFacts(items) {
|
||
const facts = items.filter(Boolean);
|
||
return facts.length ? el("div", { class: "approval-facts" }, facts) : null;
|
||
}
|
||
|
||
function titledItems(items, selectedId = null) {
|
||
const rows = (items || []).slice(0, 4).map((item, index) => {
|
||
if (item == null) return null;
|
||
if (typeof item !== "object") {
|
||
return el("li", {}, shortText(item));
|
||
}
|
||
const id = item.id || item.concept_id || item.option_id;
|
||
const title = item.title || item.name || item.display_name || item.label || id || item.path || item.platform || item.description || `Item ${index + 1}`;
|
||
const detail = item.hook || item.why_this_works || item.summary || item.description || item.silhouette_notes;
|
||
return el("li", { class: id && id === selectedId ? "selected" : "" },
|
||
el("div", { class: "approval-item-title" }, shortText(title, 100),
|
||
id && id === selectedId ? el("span", { class: "approval-selected" }, "SELECTED") : null),
|
||
detail && detail !== title ? el("p", {}, shortText(detail)) : null,
|
||
);
|
||
}).filter(Boolean);
|
||
return rows.length ? el("ul", { class: "approval-items" }, rows) : null;
|
||
}
|
||
|
||
function genericArtifactSummary(artifact) {
|
||
const facts = [];
|
||
const items = [];
|
||
for (const [key, value] of Object.entries(artifact || {})) {
|
||
if (["version", "decision_log_ref"].includes(key)) continue;
|
||
if (["string", "number", "boolean"].includes(typeof value)) {
|
||
facts.push(reviewFact(humanize(key), shortText(value, 90)));
|
||
} else if (Array.isArray(value)) {
|
||
facts.push(reviewFact(humanize(key), `${value.length} item${value.length === 1 ? "" : "s"}`));
|
||
if (!items.length && value.length) items.push(titledItems(value));
|
||
}
|
||
if (facts.length >= 6) break;
|
||
}
|
||
return [reviewFacts(facts), ...items].filter(Boolean);
|
||
}
|
||
|
||
function artifactReviewContent(name, artifact) {
|
||
if (name === "brief") {
|
||
return [
|
||
artifact.hook ? el("p", { class: "approval-lead" }, artifact.hook) : null,
|
||
reviewFacts([
|
||
reviewFact("platform", artifact.target_platform),
|
||
reviewFact("duration", artifact.target_duration_seconds != null ? fmtDuration(artifact.target_duration_seconds) : null),
|
||
reviewFact("tone", artifact.tone),
|
||
reviewFact("style", artifact.style),
|
||
]),
|
||
titledItems(artifact.key_points),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "proposal_packet") {
|
||
const selected = (artifact.selected_concept || {}).concept_id;
|
||
const plan = artifact.production_plan || {};
|
||
const cost = artifact.cost_estimate || {};
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("runtime", plan.render_runtime),
|
||
reviewFact("pipeline", plan.pipeline),
|
||
reviewFact("estimated cost", cost.total_estimated_usd != null ? fmtMoney(cost.total_estimated_usd) : null),
|
||
reviewFact("concepts", Array.isArray(artifact.concept_options) ? artifact.concept_options.length : null),
|
||
]),
|
||
titledItems(artifact.concept_options, selected),
|
||
(artifact.selected_concept || {}).rationale
|
||
? el("p", { class: "approval-rationale" },
|
||
el("b", {}, "WHY THIS CONCEPT "), shortText(artifact.selected_concept.rationale))
|
||
: null,
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "research_brief") {
|
||
return [
|
||
artifact.topic ? el("p", { class: "approval-lead" }, artifact.topic) : null,
|
||
reviewFacts([
|
||
reviewFact("sources", Array.isArray(artifact.sources) ? artifact.sources.length : null),
|
||
reviewFact("data points", Array.isArray(artifact.data_points) ? artifact.data_points.length : null),
|
||
reviewFact("angles", Array.isArray(artifact.angles_discovered) ? artifact.angles_discovered.length : null),
|
||
]),
|
||
titledItems(artifact.angles_discovered),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "script") {
|
||
const first = (artifact.sections || [])[0];
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("duration", fmtDuration(artifact.total_duration_seconds)),
|
||
reviewFact("sections", (artifact.sections || []).length),
|
||
]),
|
||
first && first.text ? el("p", { class: "approval-lead" }, shortText(first.text, 220)) : null,
|
||
el("p", { class: "approval-guidance" }, "The complete script preview is shown directly below."),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "scene_plan") {
|
||
const scenes = artifact.scenes || [];
|
||
const end = scenes.reduce((max, scene) => Math.max(max, Number(scene.end_seconds) || 0), 0);
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("scenes", scenes.length),
|
||
reviewFact("duration", end ? fmtDuration(end) : null),
|
||
]),
|
||
titledItems(scenes),
|
||
el("p", { class: "approval-guidance" }, "Review timing and shot coverage in the storyboard below."),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "asset_manifest") {
|
||
const assets = artifact.assets || [];
|
||
const types = [...new Set(assets.map((asset) => asset.type).filter(Boolean))];
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("assets", assets.length),
|
||
reviewFact("types", types.join(", ")),
|
||
reviewFact("generation cost", artifact.total_cost_usd != null ? fmtMoney(artifact.total_cost_usd) : null),
|
||
]),
|
||
titledItems(assets),
|
||
el("p", { class: "approval-guidance" }, "Inspect every generated take in the filmstrip below before approving compose."),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "edit_decisions") {
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("cuts", Array.isArray(artifact.cuts) ? artifact.cuts.length : null),
|
||
reviewFact("runtime", artifact.render_runtime || (artifact.metadata || {}).render_runtime),
|
||
]),
|
||
titledItems(artifact.cuts),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "render_report") {
|
||
return [
|
||
reviewFacts([
|
||
reviewFact("outputs", Array.isArray(artifact.outputs) ? artifact.outputs.length : null),
|
||
reviewFact("duration", artifact.duration_seconds != null ? fmtDuration(artifact.duration_seconds) : null),
|
||
]),
|
||
titledItems(artifact.outputs),
|
||
].filter(Boolean);
|
||
}
|
||
if (name === "publish_log") {
|
||
return [
|
||
reviewFacts([reviewFact("destinations", Array.isArray(artifact.entries) ? artifact.entries.length : null)]),
|
||
titledItems((artifact.entries || []).map((entry) => ({
|
||
title: entry.platform || entry.destination || "Publish destination",
|
||
description: [entry.status, entry.url].filter(Boolean).join(" · "),
|
||
}))),
|
||
].filter(Boolean);
|
||
}
|
||
return genericArtifactSummary(artifact);
|
||
}
|
||
|
||
function artifactReviewTitle(name, artifact, s) {
|
||
if (name === "proposal_packet") {
|
||
const selected = (artifact.selected_concept || {}).concept_id;
|
||
const concept = (artifact.concept_options || []).find((item) => item.id === selected);
|
||
return (concept && concept.title) || "Production proposal";
|
||
}
|
||
if (name === "research_brief") return artifact.topic || "Research brief";
|
||
if (name === "scene_plan") return "Scene plan";
|
||
if (name === "asset_manifest") return "Generated assets";
|
||
if (name === "edit_decisions") return "Edit decisions";
|
||
if (name === "render_report") return "Render report";
|
||
if (name === "publish_log") return "Publish plan";
|
||
return artifact.title || artifact.name || s.title;
|
||
}
|
||
|
||
function renderApprovalReview(s) {
|
||
const awaiting = s.stages.find((item) => item.status === "awaiting_human");
|
||
if (!awaiting) return null;
|
||
|
||
const names = artifactNamesForStage(awaiting);
|
||
const entries = names
|
||
.filter((name) => name !== "decision_log")
|
||
.map((name) => [name, s.artifacts[name]])
|
||
.filter(([, artifact]) => artifact && typeof artifact === "object");
|
||
const stageIndex = s.stages.findIndex((item) => item.name === awaiting.name);
|
||
const nextStage = stageIndex >= 0 ? s.stages[stageIndex + 1] : null;
|
||
const review = awaiting.review || {};
|
||
const reviewSummary = reviewSummaryText(review);
|
||
|
||
const artifacts = entries.map(([name, artifact]) => el("article", {
|
||
class: "approval-artifact",
|
||
"data-artifact": name,
|
||
},
|
||
el("div", { class: "approval-artifact-kicker" }, humanize(name)),
|
||
el("h2", {}, artifactReviewTitle(name, artifact, s)),
|
||
...artifactReviewContent(name, artifact),
|
||
));
|
||
|
||
if (!artifacts.length) {
|
||
artifacts.push(el("div", { class: "approval-missing", role: "alert" },
|
||
el("b", {}, "Nothing reviewable was found. "),
|
||
names.length
|
||
? `The ${awaiting.name} checkpoint declares ${names.map(humanize).join(", ")}, but Backlot could not load it.`
|
||
: `The ${awaiting.name} checkpoint does not declare an artifact.`,
|
||
));
|
||
}
|
||
|
||
return el("section", { class: "approval-review", "data-stage": awaiting.name },
|
||
el("div", { class: "approval-review-head" },
|
||
el("div", {},
|
||
el("div", { class: "approval-eyebrow" }, "REVIEW GATE"),
|
||
el("h2", {}, `${humanize(awaiting.name)} is ready for your review`),
|
||
el("p", {}, "Review the artifact here, then reply in chat to approve it or request changes."),
|
||
),
|
||
el("span", { class: "approval-status" }, "PENDING APPROVAL"),
|
||
),
|
||
reviewSummary ? el("div", { class: "approval-review-note" },
|
||
el("b", {}, "SELF-REVIEW "), shortText(reviewSummary, 260)) : null,
|
||
el("div", { class: "approval-artifacts" }, artifacts),
|
||
el("div", { class: "approval-review-foot" },
|
||
el("span", {}, nextStage
|
||
? `Approval unlocks ${humanize(nextStage.name)}.`
|
||
: "This is the final approval gate."),
|
||
el("button", { type: "button", onclick: () => toggleDrawer(awaiting.name) }, "OPEN FULL ARTIFACT"),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 openNarrModal(card) {
|
||
modal.innerHTML = "";
|
||
const meta = [sceneLabel(card.id), card.section_label, fmtDuration(card.duration_seconds)]
|
||
.filter(Boolean).join(" · ");
|
||
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-meta" }, meta),
|
||
card.narration ? el("div", { class: "sp-action", style: "margin-left:0" }, card.narration) : null,
|
||
card.shot_intent ? el("div", { class: "sp-paren", style: "margin-left:0" }, `Intent — ${card.shot_intent}`) : null,
|
||
card.description ? el("div", { class: "sp-paren", style: "margin-left:0" }, card.description) : null,
|
||
)),
|
||
);
|
||
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" });
|
||
// Collapse by category+subject: a decision that changed mid-run (e.g. voice
|
||
// openai_onyx → chirp3) is superseded by the later entry — show the CURRENT
|
||
// choice, not the first one recorded, and mark that it was revised.
|
||
const current = new Map();
|
||
decisions.forEach((d, i) => {
|
||
const key = `${d.category || "decision"}::${d.subject || ""}`;
|
||
const prev = current.get(key);
|
||
current.set(key, { d, order: i, revised: prev ? prev.revised + 1 : 0 });
|
||
});
|
||
const shown = [...current.values()].sort((a, b) => b.order - a.order).slice(0, 8);
|
||
for (const { d, revised } of shown) {
|
||
const selLabel = (() => {
|
||
// Prefer the human label of the selected option over its bare id.
|
||
const opt = (d.options_considered || []).find((o) => (o.option_id ?? o.label) === d.selected);
|
||
return (opt && opt.label) || d.selected || "";
|
||
})();
|
||
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}` : ""}`,
|
||
revised ? el("span", { class: "d-revised" }, " · revised") : null),
|
||
el("div", { class: "d-pick" }, `${d.subject || ""} `, el("span", { class: "arrow" }, "→"), ` ${selLabel}`),
|
||
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 {
|
||
const img = el("img", { src: thumbURL(s.project_id, v.path, 640), loading: "lazy", alt: "" });
|
||
// A thumbnail that fails to load must never show a broken-image icon —
|
||
// fall back to the shot spec in place (F: broken links).
|
||
img.onerror = () => {
|
||
const t = img.closest(".thumb");
|
||
if (!t) return;
|
||
t.className = "thumb spec";
|
||
t.innerHTML = "";
|
||
t.append(el("div", { class: "spec-in" },
|
||
el("div", { class: "spec-desc" }, card.description || "asset unavailable"),
|
||
el("div", { class: "spec-shot" }, [card.framing, card.movement].filter(Boolean).join(" · ").slice(0, 70))));
|
||
};
|
||
thumb = el("div", { class: "thumb approved" }, img,
|
||
v.snapshot ? el("span", { class: "badge" }, "snapshot") : (badge ? el("span", { class: "badge" }, badge) : null));
|
||
}
|
||
} else if (card.type === "animation") {
|
||
// Bespoke/atelier scene with no snapshot yet — name it as such rather
|
||
// than "no asset yet" (the composition IS the asset).
|
||
thumb = el("div", { class: "thumb spec bespoke" },
|
||
el("div", { class: "spec-in" },
|
||
el("span", { class: "bespoke-tag" }, "◆ BESPOKE"),
|
||
el("div", { class: "spec-desc" }, card.description || ""),
|
||
el("div", { class: "spec-shot" }, "hand-authored composition")));
|
||
} 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:calc(8.5px * var(--fs-scale));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 = card.visual && (
|
||
t === card.visual
|
||
|| (t.path && t.path === card.visual.path)
|
||
|| (t.id && t.id === card.visual.id)
|
||
);
|
||
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 — clickable to read in full (F: narration text cut off)
|
||
if (card.narration) {
|
||
const long = card.narration.length > 90;
|
||
wrap.append(el("div", {
|
||
class: `narr${long ? " clip" : ""}`,
|
||
title: "Click to read the full narration",
|
||
onclick: () => openNarrModal(card),
|
||
}, card.narration, long ? el("span", { class: "narr-more" }, "⤢") : null));
|
||
} 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);
|
||
// preload="metadata" gives the element its intrinsic aspect ratio (and a
|
||
// poster frame) before playback — without it a portrait 9:16 render sits
|
||
// in a letterboxed 100%-wide black box that reads as landscape.
|
||
const video = el("video", { src, controls: "", preload: "metadata" });
|
||
// Click the frame to start playback (controls handle pause/scrub) — the
|
||
// big player was inert to a click on the picture itself.
|
||
video.addEventListener("click", () => { if (video.paused) video.play().catch(() => {}); });
|
||
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:calc(15px * var(--fs-scale))" }, "◌"),
|
||
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:calc(16px * var(--fs-scale))" }, "◈"),
|
||
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 approvalReview = renderApprovalReview(s);
|
||
if (approvalReview) main.append(approvalReview);
|
||
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);
|
||
|
||
// Media sections live INSIDE the main column so a tall decisions rail
|
||
// never pushes them below the fold — the column flows beside the rail.
|
||
const storyboard = renderStoryboard(s);
|
||
const found = renderFoundMedia(s);
|
||
const renders = renderRenders(s);
|
||
|
||
if (approvalReview || script || decisions || activity) {
|
||
for (const section of [storyboard, found, renders]) {
|
||
if (section) main.append(section);
|
||
}
|
||
const hasAside = Boolean(decisions || activity);
|
||
app.append(el("div", { class: `board${hasAside ? "" : " solo"}` }, main, hasAside ? aside : null));
|
||
} else {
|
||
for (const section of [storyboard, found, renders]) {
|
||
if (section) app.append(section);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 : [];
|
||
for (const stage of s.stages) {
|
||
stage.produces = Array.isArray(stage.produces) ? stage.produces : [];
|
||
}
|
||
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));
|
||
}
|