import type { PreviewPortalAsset, PreviewPortalBrand, PreviewPortalGeneration, PreviewPortalIndexRenderOptions, PreviewPortalProject, PreviewPortalReferenceSlot, PreviewPortalRenderOptions, PreviewPortalRunState, PreviewPortalScene, PreviewPortalSurface, PreviewPortalTemplate, PreviewPortalVoiceClone, } from './types.js'; import { PORTAL_CLIENT_JS, PORTAL_CSS, PORTAL_EDITOR_JS, PORTAL_JS, PORTAL_REVIEW_TABS_JS, PORTAL_RUN_JS } from './shared-assets.js'; import { resolvePreviewPortalTemplate } from './templates.js'; export function renderPreviewPortalHtml(options: PreviewPortalRenderOptions): string { if (options.surface === 'compare') { return renderComparePortalHtml(options); } if (options.surface === 'run') { return renderRunPortalHtml(options); } const mode = modeForSurface(options.surface); const { project } = options; const template = resolvePreviewPortalTemplate(project.template); // Size the media cards + hero to the project's aspect ratio (CSS keys off this // attribute) so vertical 9:16 clips render portrait, not letterboxed landscape. const aspect = aspectForProject(project); const final = project.assets.find((asset) => asset.section === 'final' && asset.kind === 'video'); const stats = [ [project.run.runId, 'run'], [template.name, 'template'], [String(project.assets.filter((asset) => asset.kind === 'video').length), 'videos'], [String(project.assets.filter((asset) => asset.kind === 'image').length), 'images'], ]; const review = isReviewSurface(options.surface); const reviewModeAttr = review ? ` data-review-mode="${options.surface === 'client-review' ? 'client' : 'editor'}"` : ''; const sections = `${final ? renderHeroVideo(final, template) : ''}${renderTemplateSections(project, template, options.surface)}${options.surface === 'preview' ? renderBrandBook(project) : ''}`; // Review surface = one feedback page with Decide|Compare tabs + an editor↔client // mode toggle. Preview stays a clean, controls-free deliverable. const content = review ? `${renderReviewBar(options.surface)}
${sections}
` : sections; return ` ${esc(project.title)} ${renderBrandTheme(project, options.surface)} ${options.surface === 'preview' ? '' : ''}
${renderBrandBar(project, options.surface)}
${labelForSurface(options.surface)} · ${esc(template.heroLabel)}${renderStatusChip(project, options.surface)}

${esc(project.title)}

${renderHeroTagline(project, options.surface)}${project.summary ? `

${esc(project.summary)}

` : ''}
${stats.map(([value, label]) => `
${esc(value)}
${esc(label)}
`).join('')}
${renderSoundtrackPlayer(options.surface, project)}
${options.surface === 'preview' ? renderFilterChips() : ''}${content} ${renderHud(options.surface)} ${renderShortcutsHelp()} `; } /** The feedback "family" — all render the single tabbed Review surface. */ function isReviewSurface(surface: PreviewPortalSurface): boolean { return surface === 'edit' || surface === 'review' || surface === 'client-review'; } /** Tab bar (Decide | Compare) + editor↔client mode toggle for the Review surface. */ function renderReviewBar(surface: PreviewPortalSurface): string { const client = surface === 'client-review'; return `
Feedback as
`; } /** The Compare tab: run/version comparison for this project (empty-state friendly). */ function renderCompareTab(project: PreviewPortalProject): string { return `

Compare

Run Comparison
Compare finals across this project's runs.
${renderCompareCard(project.projectDir, project)}
`; } export function renderPreviewPortalIndexHtml(options: PreviewPortalIndexRenderOptions): string { const generatedAt = options.generatedAt ?? new Date().toISOString(); const title = options.title ?? (options.client ? `${options.client} Review Index` : 'Videoclaw Review Index'); const stats = [ [String(options.projects.length), 'projects'], [String(options.projects.filter((project) => project.status === 'published').length), 'published'], [String(options.projects.filter((project) => project.status.includes('review')).length), 'in review'], [String(options.projects.reduce((sum, project) => sum + project.assets.length, 0)), 'assets'], ]; return ` ${esc(title)}
client portal · ${options.client ? esc(options.client) : 'all clients'}

${esc(title)}

Review, compare, and deliver generated video projects from one consistent index.

${stats.map(([value, label]) => `
${esc(value)}
${esc(label)}
`).join('')}

Projects

Project Library
${options.projects.length} project${options.projects.length === 1 ? '' : 's'} available.
${options.projects.map((project) => renderProjectIndexCard(project, { linkPrefix: options.linkPrefix ?? '', linkMode: options.linkMode ?? 'local', client: options.client ?? null, })).join('') || '
No projects found
Generate a project portal first, then rebuild this index.
'}
`; } function renderComparePortalHtml(options: PreviewPortalRenderOptions): string { const projects = options.compareProjects?.length ? options.compareProjects : [options.project]; const title = `${options.project.title} Compare`; const stats = [ [String(projects.length), 'versions'], [String(projects.filter((project) => project.assets.some((asset) => asset.section === 'final' && asset.kind === 'video')).length), 'finals'], [String(projects.reduce((sum, project) => sum + project.assets.filter((asset) => asset.kind === 'video').length, 0)), 'videos'], [String(projects.reduce((sum, project) => sum + project.assets.filter((asset) => asset.kind === 'image').length, 0)), 'images'], ]; return ` ${esc(title)}
compare · ${esc(options.project.template)}

${esc(title)}

${options.project.summary ? `

${esc(options.project.summary)}

` : ''}
${stats.map(([value, label]) => `
${esc(value)}
${esc(label)}
`).join('')}

Versions

Run Comparison
Compare finals, run metadata, and links for each generated version.
${projects.map((project) => renderCompareCard(options.project.projectDir, project)).join('')}
`; } /* ── 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. */ 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) : ''; const copy = renderRunCopyCommands(project.slug, gen.sceneIndex); return `
${esc(genLabel)}${badge}${job}
${alarm} ${clip}${frame} ${error}
scene ${gen.sceneIndex}
${scene ? `
${esc(scene.description)}
` : ''}
${contract} ${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)
`; } function renderSoundtrackPlayer(surface: PreviewPortalSurface, project: PreviewPortalProject): string { // Soundtrack player is part of the polished final showcase only. When no // soundtrack was discovered, emit nothing (no broken/empty