import { mkdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { writeTextFileAtomic } from '../atomic-write.js'; import { appendPreviewPortalAuditEvent } from './audit.js'; import { discoverPreviewPortalPortfolio, discoverPreviewPortalProject } from './discovery.js'; import { renderPreviewPortalHtml, renderPreviewPortalIndexHtml } from './render.js'; import type { PreviewPortalAsset, PreviewPortalSurface } from './types.js'; export interface GeneratePreviewPortalSurfacesOptions { root: string; projectSlug: string; runId?: string; client?: string | null; surfaces?: PreviewPortalSurface[]; } export interface GeneratePreviewPortalSurfacesResult { projectSlug: string; outputs: Array<{ surface: PreviewPortalSurface; path: string }>; /** * Non-fatal operator hints (e.g. nothing renderable was discovered). Always * present; empty when the project has media to show. */ warnings: string[]; } /** * Operator hints about a discovered project. Pure and deterministic. The one * that matters in practice: a project with NO video/image assets renders an * empty preview — almost always because finals were staged at the workspace * root instead of `projects//final/{videos,images,audio}` (the only place * the portal scans). Turn that silent blank page into an actionable message. */ export function previewPortalMediaWarnings( projectSlug: string, assets: readonly PreviewPortalAsset[], ): string[] { const hasMedia = assets.some((asset) => asset.kind === 'video' || asset.kind === 'image'); if (hasMedia) return []; return [ `no video or image assets found for "${projectSlug}": the preview will be empty. ` + `The portal only scans UNDER the project — stage finals in ` + `projects/${projectSlug}/final/{videos,images,audio} (a final/ at the workspace root is not scanned).`, ]; } export interface GeneratePreviewPortalIndexOptions { root: string; client?: string | null; outputPath?: string; linkMode?: 'local' | 'published-run'; } export interface GeneratePreviewPortalIndexResult { client?: string | null; projectCount: number; outputPath: string; } export async function generatePreviewPortalSurfaces( options: GeneratePreviewPortalSurfacesOptions, ): Promise { // Default surfaces: the tabbed Review (Decide|Compare + editor↔client mode, // absorbing the old edit/client-review/compare), the clean Preview, and the // live Run dashboard (status badges + contract/diff cards + event log) so the // run surface is automatic for every `vclaw video portal` call. The // edit/client-review/compare surfaces still render on explicit request. const surfaces = options.surfaces ?? ['review', 'preview', 'run']; // The run dashboard reads the heavier live-run state (status + diff-vs-contract // + spend); only discover it when the run surface is actually being emitted. const project = await discoverPreviewPortalProject({ ...options, ...(surfaces.includes('run') ? { withRunState: true } : {}), }); const outputs: Array<{ surface: PreviewPortalSurface; path: string }> = []; for (const surface of surfaces) { const html = renderPreviewPortalHtml({ surface, project }); const outputPath = join(project.projectDir, fileNameForSurface(surface)); await writeTextFileAtomic(outputPath, html); outputs.push({ surface, path: outputPath }); await appendPreviewPortalAuditEvent(join(project.projectDir, 'project-audit.jsonl'), { timestamp: new Date().toISOString(), event: 'surface.generated', client: project.client, project: project.slug, run: project.run.runId, surface, template: project.template, output: fileNameForSurface(surface), assetCount: project.assets.length, status: project.status, }); } return { projectSlug: project.slug, outputs, warnings: previewPortalMediaWarnings(project.slug, project.assets), }; } export async function generatePreviewPortalIndex( options: GeneratePreviewPortalIndexOptions, ): Promise { const projects = await discoverPreviewPortalPortfolio({ root: options.root, ...(options.client ? { client: options.client } : {}), }); const outputPath = options.outputPath ?? defaultIndexPath(options.root, options.client); const html = renderPreviewPortalIndexHtml({ projects, client: options.client ?? null, title: options.client ? `${options.client} Review Index` : 'Videoclaw Review Index', linkPrefix: options.client ? '../../' : '', linkMode: options.linkMode ?? 'local', }); await mkdir(dirname(outputPath), { recursive: true }); await writeTextFileAtomic(outputPath, html); return { client: options.client ?? null, projectCount: projects.length, outputPath, }; } /** * Regenerate ONLY the live run dashboard (`projects//run.html`) from * current on-disk state. Called from the produce/execute + execute-status paths * so the dashboard repaints automatically each render/poll (no manual portal * command). Best-effort: every failure is swallowed (portal generation must * never fail the render run), and the default-ON `VCLAW_NO_RUN_SURFACE=1` * kill-switch lets byte-identical/test paths opt out. Filesystem-only (no * network), so safe in the node:test suite. */ export async function regenerateRunSurface( root: string, projectSlug: string, env: NodeJS.ProcessEnv = process.env, ): Promise { if (env.VCLAW_NO_RUN_SURFACE === '1') return; try { await generatePreviewPortalSurfaces({ root, projectSlug, surfaces: ['run'] }); } catch { // The run dashboard is observational; never block a render/poll on it. } } function fileNameForSurface(surface: PreviewPortalSurface): string { if (surface === 'edit') return 'edit.html'; if (surface === 'client-review') return 'client-review.html'; if (surface === 'review') return 'review.html'; if (surface === 'compare') return 'compare.html'; if (surface === 'index') return 'index.html'; if (surface === 'run') return 'run.html'; return 'preview.html'; } function defaultIndexPath(root: string, client: string | null | undefined): string { if (!client) return join(root, 'projects', 'index.html'); return join(root, 'projects', 'clients', slugifyClient(client), 'index.html'); } function slugifyClient(client: string): string { return client .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') || 'client'; }