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}
${renderCompareTab(project)}
`
: 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()}
${project.brand ? `${esc(project.brand.brandName)} · produced with videoclaw · ` : 'Generated by videoclaw · '}${esc(project.slug)} · ${esc(fmtDate(project.run.updatedAt))}
`;
}
/** 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 `
Decide
Compare
Feedback as
Me ↔ Claude
Client
`;
}
/** 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('') || '
'}
Generated by videoclaw · ${esc(generatedAt)}
`;
}
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('')}
Generated by videoclaw · ${esc(options.project.slug)} · ${esc(fmtDate(options.project.run.updatedAt))}
`;
}
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 element).
if (surface !== 'preview' || !project.soundtrack) return '';
const { path, label, candidates } = project.soundtrack;
// A/B candidates: render one labelled player per backend so a human can
// compare/pick. The selected candidate is flagged as the headline.
if (candidates && candidates.length > 1) {
const rows = candidates
.map(
(c) =>
`
${esc(c.label)}${c.selected ? ' · selected' : ''}
`,
)
.join('\n');
return rows;
}
return ``;
}
function renderHeroVideo(asset: PreviewPortalAsset, template: PreviewPortalTemplate): string {
return `
${esc(template.primaryAssetLabel)} ${esc(asset.path)}
`;
}
function renderProjectIndexCard(
project: PreviewPortalProject,
options: { linkPrefix: string; linkMode: 'local' | 'published-run'; client?: string | null },
): string {
const final = project.assets.find((asset) => asset.section === 'final' && asset.kind === 'video');
const poster = project.assets.find((asset) => asset.kind === 'image');
const base = projectIndexBase(project, options);
const media = final
? ` `
: poster
? ` `
: '';
return ``;
}
function projectIndexBase(
project: PreviewPortalProject,
options: { linkPrefix: string; linkMode: 'local' | 'published-run'; client?: string | null },
): string {
if (options.linkMode === 'local') return `${options.linkPrefix}${project.slug}`;
const runPath = `${project.slug}/runs/${project.run.runId}`;
if (options.client) return `${options.linkPrefix}${runPath}`;
return `${options.linkPrefix}clients/${slugify(project.client ?? 'unknown')}/${runPath}`;
}
function renderCompareCard(baseProjectDir: string, project: PreviewPortalProject): string {
const final = project.assets.find((asset) => asset.section === 'final' && asset.kind === 'video');
const prefix = relativePrefix(baseProjectDir, project.projectDir);
const media = final
? ` `
: 'No final video discovered
';
return ``;
}
function relativePrefix(baseProjectDir: string, targetProjectDir: string): string {
if (baseProjectDir === targetProjectDir) return '';
const base = baseProjectDir.split('/').filter(Boolean);
const target = targetProjectDir.split('/').filter(Boolean);
while (base.length && target.length && base[0] === target[0]) {
base.shift();
target.shift();
}
return `${base.map(() => '..').join('/')}/${target.join('/')}/`.replace(/^\//, '');
}
function renderCardsSection(
id: string,
title: string,
assets: PreviewPortalAsset[],
surface: PreviewPortalSurface,
): string {
if (assets.length === 0) return '';
return `
${esc(title)} ${esc(title)}
${assets.length} item${assets.length === 1 ? '' : 's'} discovered.
${assets.map((asset) => renderAssetCard(asset, surface)).join('')}
`;
}
/**
* Production-sheet sections that every template should surface (Cast + the
* scene/character/candidate/story sheet dirs). They are spliced in right after
* the storyboard so they read as "what we built" before the raw clip/image
* grids, without editing all six template section orders. Each renders only when
* it has content, so a template that already lists one keeps a single copy.
*/
const PRODUCTION_SHEET_SECTIONS = ['cast', 'keyframes', 'reference-sheets', 'scene-candidates', 'story-sheets'];
function effectiveSectionOrder(template: PreviewPortalTemplate): string[] {
const order = [...template.sectionOrder];
const missing = PRODUCTION_SHEET_SECTIONS.filter((section) => !order.includes(section));
if (missing.length === 0) return order;
const afterStoryboard = order.indexOf('storyboard');
const insertAt = afterStoryboard >= 0 ? afterStoryboard + 1 : Math.min(order.indexOf('brief') + 1 || 0, order.length);
order.splice(insertAt, 0, ...missing);
return order;
}
function renderTemplateSections(
project: PreviewPortalProject,
template: PreviewPortalTemplate,
surface: PreviewPortalSurface,
): string {
// When the storyboard section renders the scene keyframes, suppress those same
// sceneIndex'd keyframes from the generic generation-inputs grid (no duplicates).
const storyboardSceneIndexes = project.storyboard
? new Set(project.storyboard.scenes.map((scene) => scene.sceneIndex))
: null;
return effectiveSectionOrder(template)
.map((section) => {
if (section === 'brief') return renderBriefSection(project);
if (section === 'storyboard') return renderStoryboardSection(project, surface);
if (section === 'cast') return renderCastSection(project, surface);
let assets = project.assets.filter((asset) => asset.section === section);
if (section === 'generation-inputs' && storyboardSceneIndexes) {
assets = assets.filter(
(asset) => asset.sceneIndex === undefined || !storyboardSceneIndexes.has(asset.sceneIndex),
);
}
return renderCardsSection(sectionId(section), template.sectionLabels[section] ?? titleFromSection(section), assets, surface);
})
.join('');
}
/**
* The Cast section: one card per locked identity (Flow Character or reference
* character), with a matched still or a monogram, a source/role badge, and the
* stable id. Lets the client see who/what is identity-locked across the cut.
* Renders nothing when no cast is registered.
*/
function renderCastSection(project: PreviewPortalProject, surface: PreviewPortalSurface): string {
const cast = project.cast;
if (!cast || cast.length === 0) return '';
const cards = cast
.map((member) => {
const media = member.stillPath
? ` `
: `${esc(member.name.slice(0, 1).toUpperCase())}
`;
const badge = member.source === 'flow-character' ? 'Flow Character' : 'Reference';
const num = member.role ? `${badge} · ${member.role}` : badge;
return `
${media}
${renderControls(surface)}
`;
})
.join('');
return `
Cast Cast & Identity Locks
${cast.length} locked ${cast.length === 1 ? 'identity' : 'identities'} carried across every shot.
${cards}
`;
}
function renderBriefSection(project: PreviewPortalProject): string {
const brief = project.brief;
if (!brief) return '';
const meta = brief.metadata ?? {};
const profile = meta.executionProfile ?? {};
const chips: string[] = [];
if (typeof meta.platform === 'string') chips.push(meta.platform);
if (typeof profile.aspectRatio === 'string') chips.push(profile.aspectRatio);
if (typeof profile.resolution === 'string') chips.push(profile.resolution);
if (typeof profile.quality === 'string') chips.push(profile.quality);
if (profile.generateAudio === true) chips.push('audio on');
else if (profile.generateAudio === false) chips.push('audio off');
const chipHtml = chips.length ? `${chips.map((chip) => esc(chip)).join(' · ')}
` : '';
return `
Brief ${esc(brief.title)}
${chipHtml}
`;
}
function renderStoryboardSection(project: PreviewPortalProject, surface: PreviewPortalSurface): string {
const storyboard = project.storyboard;
if (!storyboard || storyboard.scenes.length === 0) return '';
const scenes = [...storyboard.scenes].sort((a, b) => a.sceneIndex - b.sceneIndex);
const cards = scenes
.map((scene) => {
const keyframe = project.assets.find(
(asset) => asset.kind === 'image' && asset.sceneIndex === scene.sceneIndex,
);
const sceneLabel = `Scene ${scene.sceneIndex + 1}`;
const image = keyframe
? ` `
: 'No keyframe yet
';
// When the scene has a rendered clip (`outputs/scene-.mp4`), add an inline
// (hidden by default via the card's data-mode) and a two-button
// Image/Video toggle. The clip src is project-relative — the same base the
// keyframe uses — so it resolves against the project-dir-relative HTML.
const media = scene.clipPath
? `${image} ${renderSceneToggle()}
`
: image;
const cardModeAttr = scene.clipPath ? ' data-mode="image"' : '';
const chips: string[] = [];
if (scene.characters?.length) chips.push(...scene.characters);
if (scene.scenePrompt?.cameraMove) chips.push(scene.scenePrompt.cameraMove);
if (typeof scene.durationSeconds === 'number') chips.push(`${scene.durationSeconds}s`);
const chipHtml = chips.length ? `${chips.map((chip) => esc(chip)).join(' · ')}
` : '';
const dialogue = scene.dialogue ? `“${esc(scene.dialogue)}”
` : '';
return `
${media}
${renderSceneContract(scene, project)}
${renderControls(surface)}
`;
})
.join('');
return `
Storyboard Storyboard
${scenes.length} scene${scenes.length === 1 ? '' : 's'}.
${cards}
`;
}
/**
* The per-scene Image/Video toggle (rendered only when the scene has a rendered
* clip). Two buttons drive the card's `data-mode`: "Image" (active by default)
* shows the keyframe; "▶ Video" swaps in the inline ``. The shared
* `setPortalMode` handler in `PORTAL_JS` flips the mode and pauses/loads the clip;
* the toggle CSS in `PORTAL_CSS` shows/hides image vs video. No per-card inline
* scripts — buttons are wired by the shared JS via `data-portal-mode`.
*/
function renderSceneToggle(): string {
return ``
+ `Image `
+ `▶ Video `
+ `
`;
}
/**
* Display metadata for the known narrative color states (the grade ids). The hex
* is the on-screen swatch; the label is the human-readable beat name. Unknown
* states render with a neutral swatch and the raw id, so the chip is always safe
* (the inline style only ever uses a hardcoded hex, never the untrusted id).
*/
const COLOR_STATE_DISPLAY: Record = {
'cool-steel': { label: 'Normal ops', hex: '#6A7A87' },
'crimson-threat': { label: 'Breach', hex: '#CC0000' },
'electric-blue': { label: 'Resolution', hex: '#00B4D8' },
'kodak-500t': { label: 'Film base', hex: '#C9A36A' },
'bleach-bypass': { label: 'Bleach bypass', hex: '#B8B8B0' },
desaturated: { label: 'Desaturated', hex: '#8A8A8A' },
'teal-orange': { label: 'Teal / orange', hex: '#1F7A7A' },
};
/** Render the per-scene color-state chip: a hex swatch + beat label + grade id. */
function renderColorStateChip(colorState: string): string {
const display = COLOR_STATE_DISPLAY[colorState];
const hex = display?.hex ?? '#888888';
const label = display?.label ?? colorState;
return `${esc(label)} ${esc(colorState)}
`;
}
function sectionId(section: string): string {
if (section === 'final') return 'finals';
return section.replace(/[^a-z0-9]+/gi, '-').toLowerCase();
}
function titleFromSection(section: string): string {
return section
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
.join(' ');
}
function renderAssetCard(asset: PreviewPortalAsset, surface: PreviewPortalSurface): string {
const kind = asset.section === 'final' ? 'final' : asset.section === 'videos' ? 'scene' : 'asset';
const media = asset.kind === 'video'
? ` `
: asset.kind === 'image'
? ` `
: asset.kind === 'audio'
? ``
: asset.kind === 'html'
? `⤢ Open ${esc(asset.label)} `
: `${esc(asset.path)}
`;
return `
${media}
${renderControls(surface)}
`;
}
function renderControls(surface: PreviewPortalSurface): string {
// The unified Review surface renders BOTH control sets per card; CSS shows only
// the active mode's set (editor vs client). Preview renders none.
if (!isReviewSurface(surface)) return '';
return `
Approve
Regenerate
Approve
Decline
`;
}
/** Media filter chips (preview surface only): All / Videos / Images. The viewer
* JS toggles `.card[data-media-kind]` visibility and hides emptied sections. */
function renderFilterChips(): string {
return `
All
Videos
Images
`;
}
/** Keyboard-shortcuts help overlay, toggled with `?` by the viewer JS. Rendered
* statically (hidden) so tests can assert its presence without executing JS. */
function renderShortcutsHelp(): string {
const rows: Array<[string, string]> = [
['← → or [ ]', 'Select previous / next media (page) · previous / next asset (viewer)'],
['Enter', 'Open the selected media in the viewer'],
['Esc', 'Close the viewer or this help'],
['Space / K', 'Play / pause video'],
['J / L', 'Playback slower / faster (0.25–4×)'],
[', / .', 'Frame step back / forward (Shift: 10 frames)'],
['F', 'Fullscreen video'],
['M / ↑ ↓', 'Mute · volume up / down'],
['G', 'Grab the current video frame as a PNG'],
['D', 'Download the current asset'],
['Z or click', 'Zoom image 2× (drag to pan)'],
['?', 'Toggle this help'],
];
const grid = rows
.map(([keys, what]) => `${esc(keys)} ${esc(what)} `)
.join('');
return `
Keyboard shortcuts ${grid}
`;
}
function renderHud(surface: PreviewPortalSurface): string {
// Both HUDs render; CSS shows the one matching the active mode.
if (!isReviewSurface(surface)) return '';
return `0 approved 0 to regenerate Copy Review Decisions
Client feedback Copy Feedback
`;
}
function modeForSurface(surface: PreviewPortalSurface): 'editor' | 'client' | 'preview' | 'compare' | 'run' {
if (surface === 'edit' || surface === 'review') return 'editor';
if (surface === 'client-review') return 'client';
if (surface === 'compare') return 'compare';
if (surface === 'run') return 'run';
return 'preview';
}
function labelForSurface(surface: PreviewPortalSurface): string {
if (surface === 'edit') return 'editor edit';
if (surface === 'review') return 'editor review';
if (surface === 'client-review') return 'client review';
if (surface === 'compare') return 'compare';
if (surface === 'index') return 'index';
if (surface === 'run') return 'run';
return 'preview';
}
function scriptForSurface(surface: PreviewPortalSurface): string {
// Review surface drives both control sets + the tab/mode toggle.
if (isReviewSurface(surface)) return PORTAL_EDITOR_JS + PORTAL_CLIENT_JS + PORTAL_REVIEW_TABS_JS;
return '';
}