/**
* The run dashboard surface: per-scene generation cards, status tallies,
* copy-to-terminal commands and the event log, with a meta refresh while a
* run is live. Extracted verbatim from `render.ts` (roadmap Phase 3d).
*/
import type { PreviewPortalGeneration, PreviewPortalProject, PreviewPortalRenderOptions, PreviewPortalRunState, PreviewPortalScene } from './types.js';
import { PORTAL_CSS, PORTAL_JS, PORTAL_RUN_JS } from './shared-assets.js';
import { mediaAspectAttrs, aspectForProject, fmtDate, esc, escAttr } from './render-primitives.js';
import { renderSceneContract } from './render-scene-contract.js';
/* ── Live run dashboard ──────────────────────────────────────────────────────
The run surface: per-generation status badges + the persisted-vs-current
diff-vs-contract alarm + playable in-progress clips + the prompt/contract +
an event log, rendered as Show › Episode › Generation sections with the same
TOC nav as every other surface. Auto-refreshes via a
so the open file reloads against fresh on-disk state. */
export function renderRunPortalHtml(options: PreviewPortalRenderOptions): string {
const { project } = options;
const run: PreviewPortalRunState = project.runState ?? { generations: [], events: [], refreshSeconds: 15 };
const aspect = aspectForProject(project);
const counts = tallyGenerationStatuses(run.generations);
const total = run.generations.length;
const title = run.show?.showTitle ?? project.title;
const episodeSections = renderRunEpisodes(project, run);
const logSection = renderRunEventLog(run);
const spendChip = run.spend
? `est $${run.spend.estimateUsd.toFixed(2)} (${esc(run.spend.estimateSource)}) · ~${run.spend.wallTimeMinutes}min${
run.spend.actualUsd !== undefined ? ` · actual $${run.spend.actualUsd.toFixed(2)}` : ''
}`
: '';
const links = renderRunSurfaceLinks(project);
return `
${esc(title)} — Run Status
run dashboard${run.show?.style ? ` · ${esc(run.show.style)}` : ''}auto-refresh ${run.refreshSeconds}s
${links}
${episodeSections}
${logSection}
`;
}
function tallyGenerationStatuses(
generations: PreviewPortalGeneration[],
): { done: number; rendering: number; pending: number; failed: number } {
const counts = { done: 0, rendering: 0, pending: 0, failed: 0 };
for (const gen of generations) {
if (gen.status === 'done') counts.done += 1;
else if (gen.status === 'rendering') counts.rendering += 1;
else if (gen.status === 'failed') counts.failed += 1;
else counts.pending += 1;
}
return counts;
}
/** Deep-links to the polished assembled surfaces (generated alongside this one
* by `vclaw video portal`). The dashboard's own generation cards carry the
* in-progress clips inline, so these are convenience links to the delivery
* pages; the browser handles a not-yet-generated target. */
function renderRunSurfaceLinks(project: PreviewPortalProject): string {
const links = [
['preview.html', 'Preview'],
['review.html', 'Review'],
['client-review.html', 'Client'],
]
.map(([file, label]) => `${esc(label as string)}`)
.join(' · ');
return `
Assembled surfaces: ${links}
`;
}
/**
* One `` per show-bible episode (each `data-toc`'d so the TOC lists
* episodes), each holding a `.grid` of its generation cards. When there is no
* show-bible, emit a single synthetic "Generations" section over every
* generation. Generation→episode binding is by order (the show-bible episode
* scene list is loglines, not sceneIndices); absent a binding every generation
* lands in the single section.
*/
function renderRunEpisodes(project: PreviewPortalProject, run: PreviewPortalRunState): string {
const sceneByIndex = new Map((project.storyboard?.scenes ?? []).map((s) => [s.sceneIndex, s]));
const cards = run.generations
.map((gen) => renderGenerationCard(gen, sceneByIndex.get(gen.sceneIndex), project))
.join('');
if (cards.length === 0) {
return `
Generations
Generations
No generations yet — submit a render to populate the dashboard.
`;
}
// With a show-bible, head the single generation grid under the first episode
// and list the rest as nav anchors (logline cards) so the Show › Episode
// structure reads even though generation→episode binding is not persisted.
const head = episodes[0];
const rest = episodes.slice(1);
const restSections = rest
.map(
(ep) => `
⚠ ${esc(gen.divergenceReason ?? 'submitted payload diverged from the contract')}${
gen.divergedFields?.length ? `diverged: ${esc(gen.divergedFields.join(', '))}` : ''
}
`
: '';
const contract = scene ? renderSceneContract(scene, project) : '';
// The transport's own wire body, frozen in the run contract: the exact JSON
// the vendor parses, or the planner's refusal — shown before any spend.
// A refusal renders as an error; a body renders verbatim; a contract entry
// with neither (schema-valid, never written by us) renders nothing rather
// than throwing and taking the whole page down.
const wireEntry = gen.submittedProviderWire;
const wire = wireEntry?.refused
? `
${esc(`${wireEntry.transport} would refuse this scene: ${wireEntry.refused}`)}
`
: wireEntry?.body !== undefined
? `Exact JSON → ${esc(wireEntry.transport)}${wireEntry.endpoint ? ` · ${esc(wireEntry.endpoint)}` : ''} · local reference paths are inlined or hosted at submit