mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-12 20:04:01 +08:00
fix(backlot): surface approval artifacts before gates
This commit is contained in:
@@ -69,6 +69,10 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
|
||||
{
|
||||
"name": s["name"],
|
||||
"gated": bool(s.get("human_approval_default", False)),
|
||||
"produces": [
|
||||
str(name) for name in (s.get("produces") or [])
|
||||
if isinstance(name, str) and name
|
||||
],
|
||||
}
|
||||
for s in manifest.get("stages", [])
|
||||
if isinstance(s, dict) and s.get("name")
|
||||
@@ -83,7 +87,7 @@ def _load_pipeline_meta(pipeline_type: Optional[str]) -> dict[str, Any]:
|
||||
pass
|
||||
return {
|
||||
"pipeline_type": pipeline_type or "unknown",
|
||||
"stages": [{"name": s, "gated": False} for s in FALLBACK_STAGES],
|
||||
"stages": [{"name": s, "gated": False, "produces": []} for s in FALLBACK_STAGES],
|
||||
"known": False,
|
||||
}
|
||||
|
||||
@@ -152,6 +156,7 @@ def _build_stage_rail(
|
||||
entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"gated": stage_def["gated"],
|
||||
"produces": list(stage_def.get("produces") or []),
|
||||
"status": status or "pending",
|
||||
"timestamp": cp.get("timestamp") if cp else None,
|
||||
"review": cp.get("review") if cp else None,
|
||||
@@ -188,6 +193,11 @@ def _build_stage_rail(
|
||||
entry = {
|
||||
"name": name,
|
||||
"gated": False,
|
||||
"produces": [
|
||||
str(artifact_name)
|
||||
for artifact_name in (cp.get("artifacts") or {})
|
||||
if isinstance(artifact_name, str) and artifact_name
|
||||
],
|
||||
"status": cp.get("status") or "unknown",
|
||||
"timestamp": cp.get("timestamp"),
|
||||
"review": cp.get("review"),
|
||||
|
||||
@@ -496,6 +496,7 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.board { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; }
|
||||
.board.solo { grid-template-columns: minmax(0, 1fr); }
|
||||
.main-col { min-width: 0; display: flex; flex-direction: column; gap: 22px; }
|
||||
|
||||
.panel { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 12px; }
|
||||
@@ -565,12 +566,92 @@ aside .panel:nth-of-type(1) { animation-delay: .32s } aside .panel:nth-of-type(2
|
||||
font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); color: var(--cream-ink-2); letter-spacing: .06em;
|
||||
z-index: 2;
|
||||
}
|
||||
.script-approved {
|
||||
.script-status {
|
||||
position: absolute; top: 20px; right: 26px;
|
||||
font-family: var(--mono); font-size: calc(10px * var(--fs-scale)); font-weight: 600; letter-spacing: .14em;
|
||||
color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px;
|
||||
padding: 3px 8px; transform: rotate(6deg); opacity: .8;
|
||||
}
|
||||
.script-approved { color: #2c7a4b; border: 1.5px solid #2c7a4b; border-radius: 3px; }
|
||||
.script-pending { color: #9a5b00; border: 1.5px solid #9a5b00; border-radius: 3px; }
|
||||
.script-draft { color: #59657a; border: 1.5px solid #59657a; border-radius: 3px; }
|
||||
|
||||
/* ---------- approval review surface ---------- */
|
||||
.approval-review {
|
||||
width: 100%; max-width: 1000px; margin: 0 auto;
|
||||
border: 1px solid color-mix(in srgb, var(--amber) 52%, var(--border-soft));
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0, color-mix(in srgb, var(--amber) 13%, transparent), transparent 42%),
|
||||
var(--surface);
|
||||
box-shadow: 0 28px 76px -54px rgba(0,0,0,.9);
|
||||
overflow: hidden;
|
||||
animation: rise .55s cubic-bezier(.2,.7,.3,1) .18s backwards;
|
||||
}
|
||||
.approval-review-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 24px;
|
||||
padding: 24px 26px 20px; border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.approval-review-head h2 {
|
||||
margin: 4px 0 6px; color: var(--text); font-size: calc(21px * var(--fs-scale));
|
||||
line-height: 1.2; text-transform: capitalize;
|
||||
}
|
||||
.approval-review-head p { max-width: 650px; color: var(--text-2); font-size: calc(12.5px * var(--fs-scale)); line-height: 1.5; }
|
||||
.approval-eyebrow, .approval-status, .approval-artifact-kicker,
|
||||
.approval-selected, .approval-rationale b, .approval-review-note b,
|
||||
.approval-review-foot, .approval-fact span {
|
||||
font-family: var(--mono); letter-spacing: .11em; text-transform: uppercase;
|
||||
}
|
||||
.approval-eyebrow { color: var(--amber); font-size: calc(9.5px * var(--fs-scale)); }
|
||||
.approval-status {
|
||||
flex: none; color: var(--amber); border: 1px solid var(--amber); border-radius: 99px;
|
||||
padding: 5px 9px; font-size: calc(9px * var(--fs-scale)); white-space: nowrap;
|
||||
}
|
||||
.approval-review-note {
|
||||
margin: 18px 26px 0; padding: 10px 12px; border-left: 2px solid var(--amber);
|
||||
background: color-mix(in srgb, var(--amber) 7%, var(--surface-2));
|
||||
color: var(--text-2); font-size: calc(11.5px * var(--fs-scale)); line-height: 1.5;
|
||||
}
|
||||
.approval-review-note b { color: var(--amber); font-size: calc(9px * var(--fs-scale)); }
|
||||
.approval-artifacts { display: grid; gap: 14px; padding: 20px 26px 22px; }
|
||||
.approval-artifact {
|
||||
padding: 20px; border: 1px solid var(--border-soft); border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--surface-2) 72%, transparent);
|
||||
}
|
||||
.approval-artifact-kicker { color: var(--amber); font-size: calc(9px * var(--fs-scale)); }
|
||||
.approval-artifact > h2 { margin: 5px 0 14px; font-size: calc(18px * var(--fs-scale)); line-height: 1.25; }
|
||||
.approval-lead { color: var(--text-2); font-size: calc(13px * var(--fs-scale)); line-height: 1.55; margin: 0 0 14px; }
|
||||
.approval-facts { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.approval-fact {
|
||||
display: grid; gap: 2px; min-width: 112px; padding: 8px 10px;
|
||||
border: 1px solid var(--border-soft); border-radius: 7px; background: var(--surface);
|
||||
}
|
||||
.approval-fact span { color: var(--text-3); font-size: calc(8px * var(--fs-scale)); }
|
||||
.approval-fact b { color: var(--text-2); font-size: calc(11px * var(--fs-scale)); font-weight: 600; overflow-wrap: anywhere; }
|
||||
.approval-items { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 8px; }
|
||||
.approval-items li { padding: 10px 12px; border-left: 2px solid var(--border); background: var(--surface); }
|
||||
.approval-items li.selected { border-left-color: var(--amber); }
|
||||
.approval-item-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--text); font-size: calc(12px * var(--fs-scale)); font-weight: 600; }
|
||||
.approval-items p { color: var(--text-3); font-size: calc(11px * var(--fs-scale)); line-height: 1.45; margin-top: 3px; }
|
||||
.approval-selected { color: var(--amber); font-size: calc(8px * var(--fs-scale)); font-weight: 500; }
|
||||
.approval-rationale, .approval-guidance { color: var(--text-3); font-size: calc(10.5px * var(--fs-scale)); line-height: 1.5; margin-top: 12px; }
|
||||
.approval-rationale b { color: var(--text-2); font-size: calc(8.5px * var(--fs-scale)); }
|
||||
.approval-guidance { color: var(--amber); }
|
||||
.approval-missing {
|
||||
padding: 16px; border: 1px solid color-mix(in srgb, var(--red) 45%, var(--border));
|
||||
border-radius: 9px; background: var(--red-dim); color: var(--text-2); font-size: calc(12px * var(--fs-scale));
|
||||
}
|
||||
.approval-missing b { color: var(--red); }
|
||||
.approval-review-foot {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 14px 26px; border-top: 1px solid var(--border-soft);
|
||||
color: var(--text-3); font-size: calc(9px * var(--fs-scale));
|
||||
}
|
||||
.approval-review-foot button {
|
||||
border: 1px solid var(--border); border-radius: 99px; padding: 6px 11px;
|
||||
background: var(--surface-2); color: var(--text-2); font: inherit; letter-spacing: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.approval-review-foot button:hover, .approval-review-foot button:focus-visible { color: var(--amber); border-color: var(--amber); outline: none; }
|
||||
|
||||
/* ---------- right rail: decisions & activity ---------- */
|
||||
.decision { padding: 11px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
@@ -866,7 +947,8 @@ a { color: inherit; }
|
||||
body:not(.first) .slate, body:not(.first) .rail .stage,
|
||||
body:not(.first) .script-card, body:not(.first) .notice,
|
||||
body:not(.first) aside .panel, body:not(.first) .scene-card,
|
||||
body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
body:not(.first) .lib-card, body:not(.first) .drawer,
|
||||
body:not(.first) .approval-review { animation: none; }
|
||||
|
||||
/* stages that ran but aren't declared by the pipeline manifest */
|
||||
.stage.undeclared .node { border-style: dashed; opacity: .85; }
|
||||
@@ -919,6 +1001,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
}
|
||||
.section-title { flex-wrap: wrap; }
|
||||
.section-title .meta { margin-left: 0; }
|
||||
.approval-review-head { flex-wrap: wrap; gap: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -940,7 +1023,7 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
padding: 24px 20px 28px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.script-approved { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; }
|
||||
.script-status { top: 14px; right: 16px; font-size: calc(9px * var(--fs-scale)); padding: 2px 6px; }
|
||||
.script-card .sp-title { font-size: calc(14px * var(--fs-scale)); padding-right: 58px; }
|
||||
.script-card .sp-meta { margin-bottom: 18px; }
|
||||
.script-card .sp-slug .tc { float: none; display: block; margin-top: 2px; }
|
||||
@@ -951,4 +1034,9 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; }
|
||||
.drawer .drawer-head { flex-wrap: wrap; }
|
||||
.drawer pre { font-size: calc(10.5px * var(--fs-scale)); }
|
||||
.scene-card { max-width: calc(100vw - 42px); }
|
||||
.approval-review-head, .approval-artifacts, .approval-review-foot { padding-left: 16px; padding-right: 16px; }
|
||||
.approval-review-note { margin-left: 16px; margin-right: 16px; }
|
||||
.approval-review-foot { align-items: flex-start; flex-direction: column; }
|
||||
.approval-review-foot button { width: 100%; }
|
||||
.approval-status { white-space: normal; }
|
||||
}
|
||||
|
||||
@@ -169,6 +169,36 @@ const STAGE_ARTIFACTS = {
|
||||
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);
|
||||
@@ -177,15 +207,17 @@ function renderDrawer(s) {
|
||||
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 ${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:calc(11.5px * var(--fs-scale));color:var(--text-2);margin-left:8px" }, st.review.summary) : null,
|
||||
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 = STAGE_ARTIFACTS[st.name] || [];
|
||||
const names = artifactNamesForStage(st);
|
||||
let shown = false;
|
||||
for (const name of names) {
|
||||
const artifact = s.artifacts[name];
|
||||
@@ -243,10 +275,17 @@ 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 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 },
|
||||
approved ? el("span", { class: "script-approved" }, "APPROVED") : null,
|
||||
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`),
|
||||
@@ -256,6 +295,238 @@ function renderScriptCard(s) {
|
||||
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;
|
||||
@@ -799,6 +1070,8 @@ function render() {
|
||||
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", {});
|
||||
@@ -813,11 +1086,12 @@ function render() {
|
||||
const found = renderFoundMedia(s);
|
||||
const renders = renderRenders(s);
|
||||
|
||||
if (script || decisions || activity) {
|
||||
if (approvalReview || script || decisions || activity) {
|
||||
for (const section of [storyboard, found, renders]) {
|
||||
if (section) main.append(section);
|
||||
}
|
||||
app.append(el("div", { class: "board" }, main, aside));
|
||||
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);
|
||||
@@ -830,6 +1104,9 @@ function render() {
|
||||
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 : [];
|
||||
|
||||
@@ -91,6 +91,9 @@ class TestBoardState:
|
||||
assert sc2["visual"]["exists"] is False # missing file flagged
|
||||
script_stage = next(x for x in s["stages"] if x["name"] == "script")
|
||||
assert script_stage["status"] == "completed"
|
||||
assert script_stage["produces"] == ["script"]
|
||||
proposal_stage = next(x for x in s["stages"] if x["name"] == "proposal")
|
||||
assert proposal_stage["produces"] == ["proposal_packet", "decision_log"]
|
||||
|
||||
def test_gate_skip_detection(self, projects_root):
|
||||
p = _make_project(projects_root, "sneaky")
|
||||
|
||||
@@ -10,16 +10,102 @@ import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from lib.checkpoint import init_project, write_checkpoint
|
||||
from scripts import backlot_screenshot_stage
|
||||
from tests.contracts.test_phase0_contracts import sample_artifact
|
||||
|
||||
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import sync_playwright # noqa: E402
|
||||
|
||||
|
||||
APPROVAL_CASES = [
|
||||
("gate-research", "framework-smoke", "research", "research_brief", "Test Topic"),
|
||||
("gate-idea", "hybrid", "idea", "brief", "Did you know?"),
|
||||
("gate-proposal", "cinematic", "proposal", "proposal_packet", "The Surprising Truth About X"),
|
||||
("gate-script", "cinematic", "script", "script", "Hello world"),
|
||||
("gate-scene-plan", "cinematic", "scene_plan", "scene_plan", "Host on camera"),
|
||||
("gate-assets", "cinematic", "assets", "asset_manifest", "asset-1"),
|
||||
("gate-edit", "documentary-montage", "edit", "edit_decisions", "cut-1"),
|
||||
("gate-compose", "cinematic", "compose", "render_report", "renders/output.mp4"),
|
||||
("gate-publish", "cinematic", "publish", "publish_log", "youtube"),
|
||||
]
|
||||
|
||||
|
||||
def _build_approval_projects() -> None:
|
||||
root = backlot_screenshot_stage.STAGE_DIR
|
||||
for project_id, pipeline_type, stage, artifact_name, _visible_text in APPROVAL_CASES:
|
||||
artifact = sample_artifact(artifact_name)
|
||||
if artifact_name == "edit_decisions":
|
||||
artifact["render_runtime"] = "ffmpeg"
|
||||
review_summary = (
|
||||
{
|
||||
"critical": 0,
|
||||
"suggestions": 1,
|
||||
"nitpicks": 0,
|
||||
"review_focus_met": "9/9",
|
||||
"schema_validation": "proposal_packet PASS",
|
||||
}
|
||||
if stage == "proposal"
|
||||
else "Artifact is ready for human review."
|
||||
)
|
||||
init_project(
|
||||
project_id,
|
||||
title=f"Approval fixture: {stage}",
|
||||
pipeline_type=pipeline_type,
|
||||
pipeline_dir=root,
|
||||
)
|
||||
write_checkpoint(
|
||||
root,
|
||||
project_id,
|
||||
stage,
|
||||
"awaiting_human",
|
||||
{artifact_name: artifact},
|
||||
pipeline_type=pipeline_type,
|
||||
review={
|
||||
"round": 1,
|
||||
"decision": "pass",
|
||||
"critical": 0,
|
||||
"suggestions": 1,
|
||||
"nitpicks": 0,
|
||||
"summary": review_summary,
|
||||
},
|
||||
)
|
||||
|
||||
# A manifest-declared custom stage/artifact proves the fallback is driven
|
||||
# by the stage contract rather than a hardcoded canonical-stage list.
|
||||
init_project(
|
||||
"gate-character-design",
|
||||
title="Approval fixture: character design",
|
||||
pipeline_type="character-animation",
|
||||
pipeline_dir=root,
|
||||
)
|
||||
write_checkpoint(
|
||||
root,
|
||||
"gate-character-design",
|
||||
"character_design",
|
||||
"awaiting_human",
|
||||
{"character_design": {
|
||||
"version": "1.0",
|
||||
"characters": [{
|
||||
"id": "ada",
|
||||
"display_name": "Ada",
|
||||
"role": "explorer",
|
||||
"body_type": "round",
|
||||
"style": "flat graphic",
|
||||
"silhouette_notes": "Round explorer with a bright orange field jacket",
|
||||
"required_emotions": ["curious"],
|
||||
"required_actions": ["wave"],
|
||||
}],
|
||||
}},
|
||||
pipeline_type="character-animation",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def staged_backlot_server():
|
||||
backlot_screenshot_stage.build_stage()
|
||||
_build_approval_projects()
|
||||
port = 4897
|
||||
env = dict(os.environ)
|
||||
env["OPENMONTAGE_PROJECTS_DIR"] = str(backlot_screenshot_stage.STAGE_DIR)
|
||||
@@ -57,6 +143,8 @@ def test_project_pages_fit_mobile_and_tablet_widths(staged_backlot_server):
|
||||
"/p/the-slow-orchard?static=1",
|
||||
"/p/the-last-lighthouse?static=1",
|
||||
"/p/paper-boats?static=1",
|
||||
"/p/gate-proposal?static=1",
|
||||
"/p/gate-character-design?static=1",
|
||||
]
|
||||
viewports = [
|
||||
{"width": 390, "height": 844},
|
||||
@@ -108,3 +196,69 @@ def test_static_navigation_invalid_route_and_active_takes(staged_backlot_server)
|
||||
assert page.locator(".takes .tk.active").count() >= 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("project_id", "_pipeline_type", "stage", "artifact_name", "visible_text"),
|
||||
APPROVAL_CASES,
|
||||
)
|
||||
def test_every_canonical_gate_promotes_its_artifact_before_approval(
|
||||
staged_backlot_server,
|
||||
project_id,
|
||||
_pipeline_type,
|
||||
stage,
|
||||
artifact_name,
|
||||
visible_text,
|
||||
):
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
try:
|
||||
page.goto(
|
||||
staged_backlot_server + f"/p/{project_id}?static=1",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
review = page.locator(f'.approval-review[data-stage="{stage}"]')
|
||||
assert review.is_visible()
|
||||
assert review.get_by_text("PENDING APPROVAL", exact=True).is_visible()
|
||||
assert "[object Object]" not in review.inner_text()
|
||||
artifact = review.locator(f'[data-artifact="{artifact_name}"]')
|
||||
assert artifact.is_visible()
|
||||
assert visible_text in artifact.inner_text()
|
||||
|
||||
review.get_by_role("button", name="OPEN FULL ARTIFACT").click()
|
||||
assert page.locator(".drawer").is_visible()
|
||||
assert visible_text in page.locator(".drawer").inner_text()
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_script_gate_keeps_script_visible_and_marks_pending_approval(staged_backlot_server):
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
try:
|
||||
page.goto(staged_backlot_server + "/p/gate-script?static=1", wait_until="networkidle")
|
||||
assert page.locator(".script-card").is_visible()
|
||||
assert page.locator(".script-pending").inner_text() == "PENDING APPROVAL"
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_manifest_declared_custom_gate_uses_generic_review_fallback(staged_backlot_server):
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
try:
|
||||
page.goto(
|
||||
staged_backlot_server + "/p/gate-character-design?static=1",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
review = page.locator('.approval-review[data-stage="character_design"]')
|
||||
assert review.is_visible()
|
||||
artifact = review.locator('[data-artifact="character_design"]')
|
||||
assert artifact.is_visible()
|
||||
assert "Ada" in artifact.inner_text()
|
||||
assert "Round explorer" in artifact.inner_text()
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
Reference in New Issue
Block a user