import type { PreviewPortalAsset, PreviewPortalIndexRenderOptions, PreviewPortalProject, PreviewPortalRenderOptions, PreviewPortalSurface, PreviewPortalTemplate } from './types.js'; import { PORTAL_CLIENT_JS, PORTAL_CSS, PORTAL_EDITOR_JS, PORTAL_JS, PORTAL_REVIEW_TABS_JS } from './shared-assets.js'; import { resolvePreviewPortalTemplate } from './templates.js'; // Phase 3d: the escape/aspect/date primitives, the scene contract, the run dashboard and the brand theming live in their own modules; render.ts keeps the surface router and the shared sections. import { mediaAspectAttrs, slugify, aspectForProject, fmtDate, esc, escAttr } from './render-primitives.js'; import { renderSceneContract } from './render-scene-contract.js'; import { renderRunPortalHtml } from './render-run.js'; import { renderBrandTheme, renderBrandBar, renderHeroTagline, renderStatusChip, renderBrandBook } from './render-brand.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('')}
`; } 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