Files
frames/scripts/generate-template-previews.ts
T
zooqueen de0942fa49 scripts: serve catalog previews from the docs site
Three scripts still reached upstream infrastructure, and running any of them
put its URLs back into the tree.

generate-catalog-pages emitted an absolute vendor CDN host into every catalog
page. The pages already checked in use root-relative /images/ paths, so a
regenerate reverted 306 of them; the base now matches what ships, and the docs
site serves the previews its own generator produces.

backfill-block-previews wrote a preview field into registry manifests from
that same CDN pattern. It is a one-shot migration nothing calls, and no
manifest carries the field it wrote. Gone.

upload-docs-images synced docs/images to an S3 bucket under an upstream AWS
profile. Its source directory does not exist, and the contributing guides
already say previews are served from the docs site and nothing is uploaded.
Gone, with the script entry and the comments that pointed at it.
2026-08-13 18:10:50 -07:00

218 lines
6.7 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Generate Template Preview Images + Videos
*
* Uses @hanzo/frames-producer to render PNG thumbnails and short MP4 preview
* videos of each built-in template.
*
* Output: docs/images/templates/<id>.png + <id>.mp4
* (docs/images/ is gitignored — the docs site serves them from its own
* /images/ path, so a deploy runs this script rather than uploading.)
*
* 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 "@hanzo/frames-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<string, { width: number; height: number; captureTime: number }> = {
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(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/audio>/g, "");
content = content.replace(/<audio[^>]*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<string>();
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<void> {
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<void> {
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<void> {
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);
});