import sharp from 'sharp'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { VclawError } from './errors.js'; import type { FilmmakingPromptsArtifact, FilmmakingReferenceSlot, FilmmakingStoryboardPanel, } from './filmmaking-prompts.js'; import { resolveProjectWorkspace } from './workspace.js'; export interface RenderStoryboardGridOptions { root?: string; projectSlug: string; output?: string; width?: number; height?: number; dryRun?: boolean; /** * Optional still images to embed into the panel cells, in panel order * (image[0] → panel 1, …). Each is a project-relative or absolute path; a * missing/unreadable entry (or `undefined`) leaves that cell as the empty * placeholder. Turns the sheet from a blank-cell spec layout into a real * shot-by-shot story sheet. */ images?: (string | undefined)[]; } export interface RenderStoryboardGridResult { projectSlug: string; outputPath: string; artifactReferencePath: string; artifactPath: string; width: number; height: number; panelCount: number; dryRun: boolean; } const DEFAULT_WIDTH = 1920; const DEFAULT_HEIGHT = 1080; const FONT_FAMILY = "'Arial', 'Helvetica', sans-serif"; export async function renderStoryboardGrid( options: RenderStoryboardGridOptions, ): Promise { const root = options.root ?? resolveWorkspaceRootFromEnv(); const workspace = resolveProjectWorkspace(options.projectSlug, root); const artifactPath = artifactPathFor(workspace, 'filmmaking-prompts'); if (!existsSync(artifactPath)) { throw new VclawError( 'asset_not_found', `storyboard-grid requires artifacts/filmmaking-prompts.json. Run vclaw video filmmaking-prompts --project ${options.projectSlug} --write first.`, { artifactPath }, ); } const width = options.width ?? DEFAULT_WIDTH; const height = options.height ?? DEFAULT_HEIGHT; if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { throw new VclawError( 'invalid_video_format', `Storyboard-grid dimensions must be positive integers (got ${width}x${height}).`, { width, height }, ); } const artifact = JSON.parse(await readFile(artifactPath, 'utf-8')) as FilmmakingPromptsArtifact; const gridPrompt = artifact.storyboardGridPrompt; const panels = gridPrompt?.panels ?? []; if (panels.length === 0) { throw new VclawError( 'asset_not_found', `storyboard-grid requires a storyboardGridPrompt with panels in artifacts/filmmaking-prompts.json (found ${panels.length}). Run vclaw video filmmaking-prompts --project ${options.projectSlug} --write first.`, { artifactPath, panelCount: panels.length }, ); } // Layout from the artifact when present (variable panel counts), else derive a // near-square grid so older 9-panel artifacts still render as 3×3. const cols = gridPrompt?.cols ?? Math.ceil(Math.sqrt(panels.length)); const rows = gridPrompt?.rows ?? Math.ceil(panels.length / cols); const outputPath = resolveOutputPath(workspace.projectDir, options.output); const artifactReferencePath = artifactPathForOutput(workspace.projectDir, outputPath); if (!options.dryRun) { const panelImages = await loadPanelImages(workspace.projectDir, options.images, panels.length); const png = await renderGridPng({ panels, panelImages, title: artifact.projectSlug, width, height, rows, cols, }); await mkdir(dirname(outputPath), { recursive: true }); await sharp(png).png().toFile(outputPath); await writeArtifact(workspace, 'filmmaking-prompts', markGridReady(artifact, artifactReferencePath)); } return { projectSlug: options.projectSlug, outputPath, artifactReferencePath, artifactPath, width, height, panelCount: panels.length, dryRun: !!options.dryRun, }; } const IMAGE_MIME: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', }; /** * Resolve up to `count` panel stills (panel order) to base64 `data:` URIs the * SVG `` cells embed directly. Missing/unreadable entries → `undefined` * (that cell stays an empty placeholder). Re-encodes through sharp to a PNG so * any odd source format renders reliably in the SVG rasterizer. */ async function loadPanelImages( projectDir: string, images: (string | undefined)[] | undefined, count: number, ): Promise<(string | undefined)[]> { if (!images || images.length === 0) return []; const out: (string | undefined)[] = []; for (let i = 0; i < count; i++) { const entry = images[i]; if (!entry) { out.push(undefined); continue; } const path = isAbsolute(entry) ? entry : join(projectDir, entry); if (!existsSync(path)) { out.push(undefined); continue; } try { const png = await sharp(await readFile(path)).png().toBuffer(); out.push(`data:image/png;base64,${png.toString('base64')}`); } catch { out.push(undefined); } } return out; } function resolveOutputPath(projectDir: string, output: string | undefined): string { if (!output) return join(projectDir, 'assets', 'storyboard-grid.png'); return isAbsolute(output) ? output : join(projectDir, output); } function artifactPathForOutput(projectDir: string, outputPath: string): string { const rel = relative(projectDir, outputPath); if (rel && !rel.startsWith('..') && !isAbsolute(rel)) { return rel.split(sep).join('/'); } return outputPath; } function markGridReady( artifact: FilmmakingPromptsArtifact, gridPath: string, ): FilmmakingPromptsArtifact { const gridSlot = ensureGridSlot(artifact.referenceMap, gridPath); const issues = artifact.issues.filter((issue) => ( issue.code !== 'storyboard-grid-pending' && !(issue.code === 'reference-slot-pending' && issue.message.includes(gridSlot.slot)) )); return { ...artifact, generatedAt: new Date().toISOString(), referenceMap: artifact.referenceMap.map((slot) => ( slot.slot === gridSlot.slot ? { ...slot, path: gridPath, status: 'ready' } : slot )), seedancePackets: artifact.seedancePackets.map((packet) => ({ ...packet, references: packet.references.map((reference) => ( reference.slot === gridSlot.slot ? { ...reference, path: gridPath, status: 'ready' } : reference )), warnings: packet.warnings.filter((warning) => !warning.includes(gridSlot.slot)), })), issues, }; } function ensureGridSlot( referenceMap: FilmmakingReferenceSlot[], gridPath: string, ): FilmmakingReferenceSlot { const existing = referenceMap.find((slot) => slot.role === 'storyboard-grid'); if (existing) return existing; const slot = `@image${referenceMap.length + 1}`; const created: FilmmakingReferenceSlot = { slot, role: 'storyboard-grid', label: '9-panel storyboard grid', path: gridPath, status: 'ready', }; referenceMap.push(created); return created; } async function renderGridPng(input: { panels: FilmmakingStoryboardPanel[]; panelImages?: (string | undefined)[]; title: string; width: number; height: number; rows: number; cols: number; }): Promise { const { rows, cols } = input; const margin = Math.round(input.width * 0.028); const gap = Math.round(input.width * 0.01); const headerHeight = Math.round(input.height * 0.085); const footerHeight = Math.round(input.height * 0.025); const gridWidth = input.width - margin * 2; const gridHeight = input.height - headerHeight - footerHeight - margin; const cellWidth = Math.floor((gridWidth - gap * (cols - 1)) / cols); const cellHeight = Math.floor((gridHeight - gap * (rows - 1)) / rows); const panelNodes = input.panels.map((panel, index) => { const col = index % cols; const row = Math.floor(index / cols); const x = margin + col * (cellWidth + gap); const y = headerHeight + row * (cellHeight + gap); return renderPanel(panel, x, y, cellWidth, cellHeight, index, input.panelImages?.[index]); }).join('\n'); const title = escapeXml(input.title.replaceAll('-', ' ').toUpperCase()); const svg = ` ${title} ${input.panels.length}-PANEL STORYBOARD GRID ${panelNodes} `; return sharp(Buffer.from(svg)).png().toBuffer(); } function renderPanel( panel: FilmmakingStoryboardPanel, x: number, y: number, width: number, height: number, index = 0, imageDataUri?: string, ): string { const stripHeight = Math.round(height * 0.28); const imageHeight = height - stripHeight; const pad = Math.round(width * 0.045); const titleSize = Math.max(18, Math.round(width * 0.045)); const bodySize = Math.max(16, Math.round(width * 0.034)); const noteSize = Math.max(13, Math.round(width * 0.026)); const lines = wrapWords(panel.beat, 46, 3); const lineNodes = lines.map((line, index) => ( `${escapeXml(line)}` )).join('\n'); const noteY = y + imageHeight + Math.round(stripHeight * 0.32); // Image cell: embed the still (cover-fit, clipped to the rounded top) when one // was provided for this panel, else the empty placeholder rect. When a still // is present the beat text moves into a translucent caption band at the top so // it stays legible over the image instead of overprinting it. const hasImage = typeof imageDataUri === 'string' && imageDataUri.length > 0; const clipId = `imgclip${index}`; const imageCell = hasImage ? ` ` : ``; return ` ${imageCell} PANEL ${panel.panel} ${escapeXml((panel.timecode ?? '').toUpperCase())} ${lineNodes} CAM: ${escapeXml(panel.cam.toUpperCase())} MOVE: ${escapeXml(panel.move.toUpperCase())} MOOD: ${escapeXml(panel.mood.toUpperCase())} `; } function wrapWords(value: string, maxChars: number, maxLines: number): string[] { const words = value.split(/\s+/).filter(Boolean); const lines: string[] = []; let current = ''; for (const word of words) { const next = current ? `${current} ${word}` : word; if (next.length > maxChars && current) { lines.push(current); current = word; } else { current = next; } if (lines.length === maxLines) break; } if (current && lines.length < maxLines) lines.push(current); if (lines.length === maxLines && words.join(' ').length > lines.join(' ').length) { lines[lines.length - 1] = `${lines[lines.length - 1].replace(/\s+\S+$/, '')}...`; } return lines; } function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }