|
| 1 | +#!/usr/bin/env tsx |
| 2 | +/** |
| 3 | + * Generate Catalog Preview Images + Videos |
| 4 | + * |
| 5 | + * Extends the template preview pipeline to handle all registry item types: |
| 6 | + * - Examples: renders index.html (same as generate-template-previews.ts) |
| 7 | + * - Blocks: renders the block's standalone HTML directly |
| 8 | + * - Components: renders the component's demo.html |
| 9 | + * |
| 10 | + * Output: docs/images/catalog/<type>/<name>.png + <name>.mp4 |
| 11 | + * |
| 12 | + * Usage: |
| 13 | + * npx tsx scripts/generate-catalog-previews.ts # all items |
| 14 | + * npx tsx scripts/generate-catalog-previews.ts --only data-chart # single item |
| 15 | + * npx tsx scripts/generate-catalog-previews.ts --type block # blocks only |
| 16 | + * npx tsx scripts/generate-catalog-previews.ts --skip-video # thumbnails only |
| 17 | + */ |
| 18 | + |
| 19 | +import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } from "node:fs"; |
| 20 | +import { join, resolve, dirname } from "node:path"; |
| 21 | +import { tmpdir } from "node:os"; |
| 22 | +import { fileURLToPath } from "node:url"; |
| 23 | +import { |
| 24 | + createFileServer, |
| 25 | + createCaptureSession, |
| 26 | + initializeSession, |
| 27 | + captureFrame, |
| 28 | + getCompositionDuration, |
| 29 | + closeCaptureSession, |
| 30 | + createRenderJob, |
| 31 | + executeRenderJob, |
| 32 | +} from "@hyperframes/producer"; |
| 33 | + |
| 34 | +const scriptDir = dirname(fileURLToPath(import.meta.url)); |
| 35 | +const repoRoot = resolve(scriptDir, ".."); |
| 36 | +const registryDir = resolve(repoRoot, "registry"); |
| 37 | + |
| 38 | +if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) { |
| 39 | + process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve( |
| 40 | + repoRoot, |
| 41 | + "packages/core/dist/hyperframe.manifest.json", |
| 42 | + ); |
| 43 | +} |
| 44 | + |
| 45 | +// ── Types ────────────────────────────────────────────────────────────────── |
| 46 | + |
| 47 | +type ItemKind = "block" | "component"; |
| 48 | + |
| 49 | +interface CatalogItem { |
| 50 | + name: string; |
| 51 | + kind: ItemKind; |
| 52 | + /** Directory containing the item's files in the registry. */ |
| 53 | + sourceDir: string; |
| 54 | + /** The HTML file to render (relative to sourceDir). */ |
| 55 | + entryFile: string; |
| 56 | +} |
| 57 | + |
| 58 | +// ── Discovery ────────────────────────────────────────────────────────────── |
| 59 | + |
| 60 | +function discoverItems(kindFilter: ItemKind | null, nameFilter: string | null): CatalogItem[] { |
| 61 | + const items: CatalogItem[] = []; |
| 62 | + |
| 63 | + // Blocks and components only — examples use the existing generate-template-previews.ts. |
| 64 | + const kinds: { kind: ItemKind; dir: string }[] = [ |
| 65 | + { kind: "block", dir: join(registryDir, "blocks") }, |
| 66 | + { kind: "component", dir: join(registryDir, "components") }, |
| 67 | + ]; |
| 68 | + |
| 69 | + for (const { kind, dir } of kinds) { |
| 70 | + if (kindFilter && kindFilter !== kind) continue; |
| 71 | + if (!existsSync(dir)) continue; |
| 72 | + |
| 73 | + for (const e of readdirSync(dir, { withFileTypes: true })) { |
| 74 | + if (!e.isDirectory()) continue; |
| 75 | + if (nameFilter && e.name !== nameFilter) continue; |
| 76 | + |
| 77 | + const sourceDir = join(dir, e.name); |
| 78 | + const manifestPath = join(sourceDir, "registry-item.json"); |
| 79 | + if (!existsSync(manifestPath)) continue; |
| 80 | + |
| 81 | + // Blocks: find the first composition file. Components: use demo.html. |
| 82 | + let entryFile: string; |
| 83 | + if (kind === "component") { |
| 84 | + entryFile = "demo.html"; |
| 85 | + } else { |
| 86 | + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); |
| 87 | + const compFile = manifest.files?.find( |
| 88 | + (f: { type: string }) => f.type === "hyperframes:composition", |
| 89 | + ); |
| 90 | + entryFile = compFile?.path ?? `${e.name}.html`; |
| 91 | + } |
| 92 | + |
| 93 | + if (!existsSync(join(sourceDir, entryFile))) continue; |
| 94 | + items.push({ name: e.name, kind, sourceDir, entryFile }); |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + if (nameFilter && items.length === 0) { |
| 99 | + const allNames = discoverItems(null, null).map((i) => i.name); |
| 100 | + console.error(`Item "${nameFilter}" not found. Available: ${allNames.join(", ")}`); |
| 101 | + process.exit(1); |
| 102 | + } |
| 103 | + |
| 104 | + return items; |
| 105 | +} |
| 106 | + |
| 107 | +// ── Preview generation ───────────────────────────────────────────────────── |
| 108 | + |
| 109 | +function outputDir(kind: ItemKind): string { |
| 110 | + const typeDir = kind === "block" ? "blocks" : "components"; |
| 111 | + return resolve(repoRoot, "docs/images/catalog", typeDir); |
| 112 | +} |
| 113 | + |
| 114 | +function prepareProjectDir(item: CatalogItem): string { |
| 115 | + const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`); |
| 116 | + mkdirSync(tmpDir, { recursive: true }); |
| 117 | + cpSync(item.sourceDir, tmpDir, { recursive: true }); |
| 118 | + return tmpDir; |
| 119 | +} |
| 120 | + |
| 121 | +async function generateThumbnail(item: CatalogItem, projectDir: string): Promise<void> { |
| 122 | + const outDir = outputDir(item.kind); |
| 123 | + mkdirSync(outDir, { recursive: true }); |
| 124 | + |
| 125 | + // Read dimensions from registry-item.json or default to 1920x1080 |
| 126 | + let width = 1920; |
| 127 | + let height = 1080; |
| 128 | + const manifestPath = join(item.sourceDir, "registry-item.json"); |
| 129 | + if (existsSync(manifestPath)) { |
| 130 | + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); |
| 131 | + if (manifest.dimensions) { |
| 132 | + width = manifest.dimensions.width ?? width; |
| 133 | + height = manifest.dimensions.height ?? height; |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + const framesDir = join(projectDir, "_thumb_frames"); |
| 138 | + mkdirSync(framesDir, { recursive: true }); |
| 139 | + |
| 140 | + const fileServer = await createFileServer({ projectDir, port: 0 }); |
| 141 | + try { |
| 142 | + const session = await createCaptureSession(fileServer.url, framesDir, { |
| 143 | + width, |
| 144 | + height, |
| 145 | + fps: 30, |
| 146 | + format: "png", |
| 147 | + }); |
| 148 | + await initializeSession(session); |
| 149 | + |
| 150 | + let duration: number; |
| 151 | + try { |
| 152 | + duration = await getCompositionDuration(session); |
| 153 | + } catch { |
| 154 | + duration = 5; |
| 155 | + } |
| 156 | + |
| 157 | + // Capture at 40% of duration for a representative frame |
| 158 | + const captureTime = Math.min(2.0, duration * 0.4); |
| 159 | + const result = await captureFrame(session, 0, captureTime); |
| 160 | + cpSync(result.path, join(outDir, `${item.name}.png`)); |
| 161 | + console.log(` ✓ ${item.name}.png (${result.captureTimeMs}ms)`); |
| 162 | + |
| 163 | + await closeCaptureSession(session); |
| 164 | + } finally { |
| 165 | + fileServer.close(); |
| 166 | + rmSync(framesDir, { recursive: true, force: true }); |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +async function generateVideo(item: CatalogItem, projectDir: string): Promise<void> { |
| 171 | + const outDir = outputDir(item.kind); |
| 172 | + mkdirSync(outDir, { recursive: true }); |
| 173 | + |
| 174 | + const outMp4 = join(outDir, `${item.name}.mp4`); |
| 175 | + const job = createRenderJob({ |
| 176 | + fps: 24, |
| 177 | + quality: "draft", |
| 178 | + format: "mp4", |
| 179 | + }); |
| 180 | + await executeRenderJob(job, projectDir, outMp4); |
| 181 | + console.log(` ✓ ${item.name}.mp4`); |
| 182 | +} |
| 183 | + |
| 184 | +// ── CLI ──────────────────────────────────────────────────────────────────── |
| 185 | + |
| 186 | +function parseArgs(): { only: string | null; type: ItemKind | null; skipVideo: boolean } { |
| 187 | + let only: string | null = null; |
| 188 | + let type: ItemKind | null = null; |
| 189 | + let skipVideo = false; |
| 190 | + |
| 191 | + for (let i = 2; i < process.argv.length; i++) { |
| 192 | + const arg = process.argv[i]; |
| 193 | + if (arg === "--only" && process.argv[i + 1]) { |
| 194 | + i++; |
| 195 | + only = process.argv[i] ?? null; |
| 196 | + } |
| 197 | + if (arg === "--type" && process.argv[i + 1]) { |
| 198 | + i++; |
| 199 | + const val = process.argv[i]; |
| 200 | + if (val === "block" || val === "component") { |
| 201 | + type = val; |
| 202 | + } else { |
| 203 | + console.error(`Invalid --type: "${val}". Must be block or component.`); |
| 204 | + process.exit(1); |
| 205 | + } |
| 206 | + } |
| 207 | + if (arg === "--skip-video") skipVideo = true; |
| 208 | + } |
| 209 | + |
| 210 | + return { only, type, skipVideo }; |
| 211 | +} |
| 212 | + |
| 213 | +async function main(): Promise<void> { |
| 214 | + const { only, type, skipVideo } = parseArgs(); |
| 215 | + const items = discoverItems(type, only); |
| 216 | + |
| 217 | + console.log( |
| 218 | + `Generating catalog previews for ${items.length} item(s)${skipVideo ? " (thumbnails only)" : " + videos"}...\n`, |
| 219 | + ); |
| 220 | + |
| 221 | + for (const item of items) { |
| 222 | + console.log(`[${item.kind}] ${item.name}`); |
| 223 | + const projectDir = prepareProjectDir(item); |
| 224 | + try { |
| 225 | + await generateThumbnail(item, projectDir); |
| 226 | + if (!skipVideo) { |
| 227 | + await generateVideo(item, projectDir); |
| 228 | + } |
| 229 | + } catch (err) { |
| 230 | + console.error(` ✗ ${item.name}: ${err instanceof Error ? err.message : err}`); |
| 231 | + } finally { |
| 232 | + rmSync(projectDir, { recursive: true, force: true }); |
| 233 | + } |
| 234 | + } |
| 235 | + |
| 236 | + console.log("\nDone."); |
| 237 | +} |
| 238 | + |
| 239 | +main().catch((err) => { |
| 240 | + console.error(err); |
| 241 | + process.exit(1); |
| 242 | +}); |
0 commit comments