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}
${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))}
`;
}
/* ── 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}
Generated by videoclaw · ${esc(project.slug)} · run dashboard · ${esc(fmtDate(project.run.updatedAt))}
`;
}
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.
`;
}
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}
${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 `
Copy re-roll
Copy approve
`;
}
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 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 ``;
}
/**
* Per-ASSET aspect attributes: class for the orientation bucket (drives the
* hero/card max-height CSS) plus an exact inline aspect-ratio when dimensions
* were probed. A 9:16 deliverable must render in a portrait card even inside a
* 16:9 project — never letterboxed in a landscape box.
*/
function mediaAspectAttrs(asset: PreviewPortalAsset): string {
const cls = asset.orientation ? ` class="orient-${asset.orientation}"` : '';
const style = asset.width && asset.height ? ` style="aspect-ratio:${asset.width}/${asset.height}"` : '';
return `${cls}${style}`;
}
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 `
+ `
`;
}
/**
* The per-scene "contract": the exact prompt text that will be submitted to the
* provider plus the identity-lock reference sheets for the scene's characters.
* This turns each storyboard card from a thumbnail-and-caption gallery into a
* "what you will actually get" review surface — the operator sees the submit
* prompt and the locked references inline, before any spend. Rendered expanded
* (no click-to-reveal) so the contract is always visible. Returns an empty
* string when a scene carries neither a prompt nor a matchable reference.
*/
function renderSceneContract(scene: PreviewPortalScene, project: PreviewPortalProject): string {
const prompt = scene.scenePrompt;
const rows: string[] = [];
// The resolved provider submit text (10-block packet) is the authoritative
// contract; show it first and in full when present.
if (scene.renderPrompt) rows.push(promptRow('Submit', scene.renderPrompt));
if (prompt?.imagePrompt) rows.push(promptRow('Image', prompt.imagePrompt));
if (prompt?.animationPrompt) rows.push(promptRow('Motion', prompt.animationPrompt));
if (prompt?.styleFooter) rows.push(promptRow('Style', prompt.styleFooter));
const promptBlock = rows.length
? `Submit prompt · what the model receives
${rows.join('')}
`
: '';
// Identity-lock references: the character reference sheets discovered for the
// names this scene casts. A character image can surface either from the
// characters.json appender (label " reference") or the generic dir scan
// (filename-based label/path), so match the slugified character name against
// both the label and the path. De-duped by path so a character listed twice
// does not double-render.
const wantNames = (scene.characters ?? []).map(contractSlug).filter(Boolean);
const seen = new Set();
const refs = project.assets.filter((asset) => {
if (asset.kind !== 'image' || asset.section !== 'characters' || seen.has(asset.path)) return false;
const haystacks = [contractSlug(asset.label), contractSlug(asset.path)];
if (!wantNames.some((name) => haystacks.some((hay) => hay.includes(name)))) return false;
seen.add(asset.path);
return true;
});
// Reference-slot contract: which slot locks this scene, ready vs pending, and
// the bound Asset:// URI for identity. Falls back to the discovered character
// thumbnails when no resolved slots exist yet.
const slotRows = (scene.referenceSlots ?? []).map(renderReferenceSlot).join('');
const thumbs = refs.length
? `${refs
.map(
(ref) =>
`
`,
)
.join('')}
`
: '';
const refBlock = slotRows || thumbs
? `References · identity lock
${slotRows ? `
${slotRows}
` : ''}${thumbs}
`
: '';
// Voice design: the cloned-voice references bound to this scene's cast. Each
// cast character with a matching voice clone gets a labeled row carrying a
// videoAssetId/videoAssetId2-style pill AND a playable so the operator
// can HEAR the voice before spend. Ordered by cast order; first → videoAssetId,
// second → videoAssetId2, etc. (matches the exact-submit JSON below).
const sceneVoices = sceneVoiceClones(scene, project);
const voiceBlock = sceneVoices.length
? `Voice design · cloned voice
${sceneVoices
.map(
(voice, index) =>
`
${esc(videoAssetIdField(index))} ${esc(voice.label)}${voice.durationSeconds ? ` · ${esc(`${voice.durationSeconds}s`)}` : ''} `,
)
.join('')}
`
: '';
// Exact submit JSON: the plain submit body that will hit the provider, in a
// collapsed so the contract is auditable without dominating the card.
// imageAssetIdN come from the scene's resolved reference slot paths; videoAssetId
// / videoAssetId2 from the voice clip filenames.
const jsonBlock = renderExactSubmitJson(scene, project, refs, sceneVoices);
if (!promptBlock && !refBlock && !voiceBlock && !jsonBlock) return '';
return `${promptBlock}${refBlock}${voiceBlock}${jsonBlock}
`;
}
/**
* The cloned-voice references bound to a scene: for each character the scene
* casts (in cast order), the project voice clone whose character matches
* (case-insensitively). De-duped per character. Empty when the project carries
* no voice clones or none match the cast — the Voice design block then omits.
*/
function sceneVoiceClones(
scene: PreviewPortalScene,
project: PreviewPortalProject,
): PreviewPortalVoiceClone[] {
const clones = project.voiceClones;
if (!clones || !clones.length) return [];
const byCharacter = new Map();
for (const clone of clones) byCharacter.set(clone.character.toLowerCase(), clone);
const out: PreviewPortalVoiceClone[] = [];
const seen = new Set();
for (const name of scene.characters ?? []) {
const key = name.toLowerCase();
if (seen.has(key)) continue;
const match = byCharacter.get(key);
if (!match) continue;
seen.add(key);
out.push(match);
}
return out;
}
/** The submit-body voice-ref field name for the Nth voice (1 → videoAssetId,
* 2 → videoAssetId2, …), matching the seedance-2 submit shape. */
function videoAssetIdField(index: number): string {
return index === 0 ? 'videoAssetId' : `videoAssetId${index + 1}`;
}
/**
* The exact-submit JSON identifier for a reference. An `Asset://` URI IS the
* identifier (the registered avatar binding) and is kept verbatim; a local path
* or hosted URL is reduced to its filename (the asset id the submit body carries).
* Falls back to the whole value when unsplittable.
*/
function submitRefId(value: string): string {
if (/^asset:\/\//i.test(value)) return value;
const noQuery = value.split(/[?#]/)[0] ?? value;
const segments = noQuery.split('/').filter(Boolean);
return segments[segments.length - 1] || value;
}
/**
* The "Exact JSON → seedance-2" block: a collapsed showing the plain
* submit body that will be sent for this scene — model, aspect_ratio, audio,
* (duration), a text_prompt placeholder, the imageAssetId1..N from the scene's
* resolved references, and the videoAssetId/videoAssetId2 from the voice clips.
* Built from the same data the rest of the contract renders, so the operator can
* audit the exact payload before spend. Empty string when there is nothing to
* show (no references and no voices).
*/
function renderExactSubmitJson(
scene: PreviewPortalScene,
project: PreviewPortalProject,
refs: PreviewPortalAsset[],
voices: PreviewPortalVoiceClone[],
): string {
// Image references: prefer the resolved reference-slot bindings (Asset:// URI or
// path), else the discovered character reference thumbnails.
const imageRefs: string[] = [];
for (const slot of scene.referenceSlots ?? []) {
const binding = slot.assetUri ?? slot.path;
if (binding) imageRefs.push(binding);
}
if (imageRefs.length === 0) {
for (const ref of refs) imageRefs.push(ref.path);
}
if (imageRefs.length === 0 && voices.length === 0) return '';
const body: Record = {
model: 'seedance-2',
aspect_ratio: aspectForProject(project),
audio: true,
...(typeof scene.durationSeconds === 'number' ? { duration: scene.durationSeconds } : {}),
text_prompt: '«submit prompt above»',
};
imageRefs.forEach((ref, index) => {
body[`imageAssetId${index + 1}`] = submitRefId(ref);
});
voices.forEach((voice, index) => {
body[videoAssetIdField(index)] = submitRefId(voice.src);
});
const json = JSON.stringify(body, null, 2);
return `Exact JSON → seedance-2 ${esc(json)} `;
}
/**
* One reference-slot row: role · label · ready/pending badge · binding (the
* Asset:// URI when registered, else the local path, else the character name
* flagged unregistered). This is the operator's pre-render identity-lock check.
*/
function renderReferenceSlot(slot: PreviewPortalReferenceSlot): string {
const badge = `${esc(slot.status)} `;
const bind = slot.assetUri
? `${esc(slot.assetUri)} `
: slot.path
? `${esc(slot.path)} `
: slot.characterName
? `${esc(slot.characterName)} · unregistered `
: '';
return `${esc(slot.role)} ${esc(slot.label)} ${badge}${bind}
`;
}
function promptRow(key: string, value: string): string {
return `${esc(key)} ${esc(value)}
`;
}
/** Lowercase alphanumeric reduction for tolerant character-name → asset matching. */
function contractSlug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
/**
* 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}
`;
}
/* ── Brand theming: palette-driven accent, client wordmark/logo, brand book ──
Sourced from artifacts/brand-definition.json (discovered onto project.brand).
No brand → every helper returns '' and the stock theme renders unchanged. */
/** Surfaces that carry the client's identity (vs the operator's utilitarian view). */
function isClientSurface(surface: PreviewPortalSurface): boolean {
return surface === 'preview' || surface === 'client-review';
}
/** WCAG relative luminance of a #RRGGBB color. */
function relativeLuminance(hex: string): number {
const channel = (i: number) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
/**
* Pick the brand accent: the first palette role (metallic → primary → secondary
* → contrast) bright enough to read on the near-black portal background
* (luminance ≥ 0.2 ≈ 4.5:1 contrast). All-dark palettes return null and the
* stock amber accent stays — readability beats brand fidelity.
*/
function pickBrandAccent(brand: PreviewPortalBrand): string | null {
for (const role of ['metallic', 'primary', 'secondary', 'contrast']) {
const color = brand.palette.find((entry) => entry.role === role);
if (color && relativeLuminance(color.hex) >= 0.2) return color.hex;
}
return null;
}
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
/** First numeric CSS weight found in the artifact's free-form weight string. */
function fontWeightOf(weight: string): string | null {
const m = /\d{3}/.exec(weight);
return m ? m[0] : null;
}
/**
* Reduce a brand font name to the characters a Google-font family legitimately
* uses (letters, digits, spaces). This is the one guard for BOTH the Google
* Fonts URL and the `:root{}` font-family values: an unsanitized name carrying
* `& ; } :` would otherwise corrupt the font-link query string or break out of
* the inline ``;
}
/** Client identity bar above the hero: logo image when discovered, else the
* brand name typeset in the wordmark face; right side names the recipient. */
function renderBrandBar(project: PreviewPortalProject, surface: PreviewPortalSurface): string {
const brand = project.brand;
if (!brand || !isClientSurface(surface)) return '';
const mark = brand.logoPath
? ` `
: `${esc(brand.brandName)} `;
const recipient = project.client ? `prepared for ${project.client}` : 'client delivery';
return `${mark}${esc(recipient)}
`;
}
/** Emotional tagline as the hero subline on client surfaces. */
function renderHeroTagline(project: PreviewPortalProject, surface: PreviewPortalSurface): string {
const line = project.brand?.taglines?.emotional;
if (!line || !isClientSurface(surface)) return '';
return `${esc(line)}
`;
}
/** Review-status chip in the hero eyebrow (preview only; silent while draft). */
function renderStatusChip(project: PreviewPortalProject, surface: PreviewPortalSurface): string {
if (surface !== 'preview' || project.status === 'draft') return '';
const approved = project.status === 'client-approved' || project.status === 'final' || project.status === 'published';
return `${esc(project.status)} `;
}
/** Prettify a camelCase palette role for the swatch label. */
function roleLabel(role: string): string {
return role.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
}
/**
* The brand-book annex (preview only): palette swatches, typography specimens
* rendered in their own faces, and the three taglines — the page doubles as a
* mini brand portal the client can keep using after delivery.
*/
function renderBrandBook(project: PreviewPortalProject): string {
const brand = project.brand;
if (!brand) return '';
const swatches = brand.palette
.map(
(color) =>
``,
)
.join('');
const specimens = brand.typography
.map((t) => {
const weight = fontWeightOf(t.weight);
const sample = t.level === 'wordmark' ? brand.brandName : 'Aa Bb Cc 0123';
// Same sanitization as the head theme: a font name with `'`/`;`/`}` would
// otherwise corrupt this inline font-family. Show the cleaned name too.
const cleanFont = sanitizeFontFamily(t.font);
const style = cleanFont
? `font-family:'${cleanFont}',var(--font-body)${weight ? `;font-weight:${weight}` : ''}`
: weight ? `font-weight:${weight}` : '';
return `${esc(t.level)} ${esc(sample)} ${esc(cleanFont || t.level)}${t.weight ? ` · ${esc(t.weight)}` : ''}
`;
})
.join('');
const taglineKinds: Array<[string, string | undefined]> = [
['functional', brand.taglines?.functional],
['emotional', brand.taglines?.emotional],
['community', brand.taglines?.community],
];
const taglines = taglineKinds.filter(([, line]) => line);
const taglineHtml = taglines.length
? `${taglines
.map(([kind, line]) => `
${esc(kind)}
${esc(line as string)}
`)
.join('')}
`
: '';
return `
Brand ${esc(brand.brandName)} Brand System
${brand.positioning ? `
${esc(brand.positioning)}
` : ''}
${swatches ? `${swatches}
` : ''}
${specimens ? `${specimens}
` : ''}
${taglineHtml}
`;
}
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 '';
}
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'unknown';
}
/** Project aspect ratio (from the brief's execution profile) used to size the
* media cards + hero. Falls back to landscape when unknown. */
function aspectForProject(project: PreviewPortalProject): string {
const ratio = project.brief?.metadata?.executionProfile?.aspectRatio;
return ratio === '9:16' || ratio === '1:1' || ratio === '16:9' ? ratio : '16:9';
}
/** Compact, locale-independent card date: "2026-06-01 18:01 UTC" from an ISO
* string (deterministic — no Date/locale parsing). Empty string when absent. */
function fmtDate(iso?: string): string {
if (!iso) return '';
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
return m ? `${m[1]} ${m[2]} UTC` : iso;
}
function esc(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
function escAttr(value: string): string {
return esc(value);
}