/** * 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

${esc(title)}

${run.show?.premise ? `

${esc(run.show.premise)}

` : project.summary ? `

${esc(project.summary)}

` : ''}
${counts.done} done ${counts.rendering} rendering ${counts.pending} pending ${counts.failed} failed ${total} generation${total === 1 ? '' : 's'} ${spendChip}
${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 ``; } /** * 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.
`; } const episodes = run.show?.episodes ?? []; if (episodes.length === 0) { return `

Generations

Generations
${run.generations.length} generation${run.generations.length === 1 ? '' : 's'}.
${cards}
`; } // 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(ep.title)}

${esc(ep.title)}
${ep.logline ? `
${esc(ep.logline)}
` : ''}
`, ) .join(''); return `

${esc(head.title)}

${esc(head.title)}
${head.logline ? `
${esc(head.logline)}
` : ''}
${cards}
${restSections}`; } const RUN_STATUS_LABEL: Record = { done: 'done', rendering: 'rendering', pending: 'pending', failed: 'failed', unknown: 'unknown', }; /** One generation card: status badge + job id + (failed) error, a playable * in-progress clip + input keyframe, the prompt/contract panels, and the * diff-vs-contract RED alarm + per-card copy-command buttons. */ function renderGenerationCard( gen: PreviewPortalGeneration, scene: PreviewPortalScene | undefined, project: PreviewPortalProject, ): string { const genLabel = `Gen ${gen.sceneIndex + 1}`; const badge = `${esc(RUN_STATUS_LABEL[gen.status])}`; const job = gen.externalJobId ? `${esc(gen.externalJobId.slice(-16))}` : ''; const clip = gen.clipPath ? `
` : ''; const keyframe = project.assets.find( (asset) => asset.kind === 'image' && asset.sceneIndex === gen.sceneIndex, ); const frame = !gen.clipPath && keyframe ? `
` : ''; const error = gen.status === 'failed' && gen.error ? `
${esc(gen.error)}
` : ''; const alarm = gen.contractDiverged ? `
⚠ ${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
${esc(JSON.stringify(wireEntry.body, null, 2))}
` : ''; const copy = renderRunCopyCommands(project.slug, gen.sceneIndex); return `
${esc(genLabel)}${badge}${job}
${alarm} ${clip}${frame} ${error}
scene ${gen.sceneIndex}
${scene ? `
${esc(scene.description)}
` : ''}
${contract} ${wire} ${copy}
`; } /** Per-card copy-command buttons (the existing copy pattern, no server): the * exact `vclaw` commands to re-roll or approve this scene's candidate. */ function renderRunCopyCommands(slug: string, sceneIndex: number): string { const reroll = `vclaw video reroll-scene --project ${slug} --scene ${sceneIndex}`; const approve = `vclaw video select-candidate --project ${slug} --scene ${sceneIndex} --candidate-id `; return `
`; } function renderRunEventLog(run: PreviewPortalRunState): string { const rows = run.events .map((event) => { const time = /T(\d{2}:\d{2}:\d{2})/.exec(event.recordedAt)?.[1] ?? ''; return `${esc(time)}${esc(event.type)}${event.summary ? esc(event.summary) : ''}`; }) .join(''); return `

Log

Event Log
${run.events.length} event${run.events.length === 1 ? '' : 's'} (most recent first).
${rows || ''}
—(no events yet)
`; }