#!/usr/bin/env tsx /** * Generate Template Preview Images + Videos * * Uses @hyperframes/producer to render PNG thumbnails and short MP4 preview * videos of each built-in template. * * Output: docs/images/templates/.png + .mp4 * (docs/images/ is gitignored — files are served from the CDN. After running * this script, run `bun run upload:docs-images` to publish.) * * Usage: * bun run generate:previews # all templates (PNG + MP4) * bun run generate:previews --only warm-grain * bun run generate:previews --skip-video # thumbnails only (faster) */ import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync, cpSync, rmSync, } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { createFileServer, createCaptureSession, initializeSession, captureFrame, getCompositionDuration, closeCaptureSession, createRenderJob, executeRenderJob, } from "@hyperframes/producer"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const bundledTemplatesDir = resolve(repoRoot, "packages/cli/src/templates"); const remoteTemplatesDir = resolve(repoRoot, "registry/examples"); const outputDir = resolve(repoRoot, "docs/images/templates"); if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) { process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve( repoRoot, "packages/core/dist/hyperframe.manifest.json", ); } const SKIP_TEMPLATES = new Set(["blank"]); const DEFAULT_CONFIG = { width: 1920, height: 1080, captureTime: 2.0 }; const TEMPLATE_CONFIG: Record = { vignelli: { width: 1080, height: 1920, captureTime: 2.0 }, }; function patchTemplateHtml(dir: string, durationSeconds: number): void { const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true }) .filter((e) => e.isFile() && e.name.endsWith(".html")) .map((e) => join(e.parentPath ?? e.path, e.name)); for (const file of htmlFiles) { let content = readFileSync(file, "utf-8"); content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, ""); content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>/g, ""); content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/audio>/g, ""); content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>/g, ""); const dur = String(Math.round(durationSeconds * 100) / 100); content = content.replaceAll("__VIDEO_DURATION__", dur); writeFileSync(file, content, "utf-8"); } } function parseArgs(): { only: string | null; skipVideo: boolean } { let only: string | null = null; let skipVideo = false; for (let i = 2; i < process.argv.length; i++) { if (process.argv[i] === "--only" && process.argv[i + 1]) { i++; only = process.argv[i] ?? null; } if (process.argv[i] === "--skip-video") skipVideo = true; } return { only, skipVideo }; } function resolveTemplateDir(templateId: string): string | null { for (const base of [bundledTemplatesDir, remoteTemplatesDir]) { const dir = join(base, templateId); if (existsSync(join(dir, "index.html"))) return dir; } return null; } function discoverTemplates(only: string | null): string[] { const seen = new Set(); const all: string[] = []; for (const dir of [bundledTemplatesDir, remoteTemplatesDir]) { if (!existsSync(dir)) continue; for (const e of readdirSync(dir, { withFileTypes: true })) { if ( e.isDirectory() && e.name !== "_shared" && !SKIP_TEMPLATES.has(e.name) && !seen.has(e.name) && existsSync(join(dir, e.name, "index.html")) ) { seen.add(e.name); all.push(e.name); } } } if (only) { if (!all.includes(only)) { console.error(`Template "${only}" not found. Available: ${all.join(", ")}`); process.exit(1); } return [only]; } return all; } function prepareTemplateDir(templateId: string): string { const tmpDir = join(tmpdir(), `hf-preview-${templateId}-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); const src = resolveTemplateDir(templateId); if (!src) throw new Error(`Template directory not found for "${templateId}"`); cpSync(src, tmpDir, { recursive: true }); patchTemplateHtml(tmpDir, 5); return tmpDir; } async function generateThumbnail(templateId: string, projectDir: string): Promise { const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG; const framesDir = join(projectDir, "_thumb_frames"); mkdirSync(framesDir, { recursive: true }); const fileServer = await createFileServer({ projectDir, port: 0, fps: { num: 30, den: 1 }, }); try { const session = await createCaptureSession(fileServer.url, framesDir, { width: config.width, height: config.height, fps: 30, format: "png", }); await initializeSession(session); let duration: number; try { duration = await getCompositionDuration(session); } catch { duration = 5; } const t = Math.min(config.captureTime, duration * 0.8); const result = await captureFrame(session, 0, t); cpSync(result.path, join(outputDir, `${templateId}.png`)); console.log(` ✓ ${templateId}.png (${result.captureTimeMs}ms)`); await closeCaptureSession(session); } finally { fileServer.close(); rmSync(framesDir, { recursive: true, force: true }); } } async function generateVideo(templateId: string, projectDir: string): Promise { const outMp4 = join(outputDir, `${templateId}.mp4`); const job = createRenderJob({ fps: 24, quality: "draft", format: "mp4", }); await executeRenderJob(job, projectDir, outMp4); console.log(` ✓ ${templateId}.mp4`); } async function main(): Promise { const { only, skipVideo } = parseArgs(); const templates = discoverTemplates(only); console.log( `Generating previews for ${templates.length} templates${skipVideo ? " (thumbnails only)" : " + videos"}...\n`, ); mkdirSync(outputDir, { recursive: true }); for (const templateId of templates) { const projectDir = prepareTemplateDir(templateId); try { await generateThumbnail(templateId, projectDir); if (!skipVideo) { await generateVideo(templateId, projectDir); } } catch (err) { console.error(` ✗ ${templateId}: ${err instanceof Error ? err.message : err}`); } finally { rmSync(projectDir, { recursive: true, force: true }); } } console.log(`\nDone. Output: ${outputDir}`); } main().catch((err) => { console.error(err); process.exit(1); });