mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 11:16:27 +00:00
* fix(scripts): render template-only blocks in catalog previews The catalog preview renderer treated any file containing `__timelines` as a standalone composition and rendered it as index.html directly. The 12 VS Code snippet blocks register their timeline inside a `<template>`, which stays inert until a host mounts it, so every one of them failed with "Composition has zero duration" and no preview could be produced from the registry at all. Six of the previews on the docs CDN were hand-made from a project still mounting Monokai, so Dark+, High Contrast, High Contrast Light, Solarized Light, Visual Studio Dark and Visual Studio Light all showed Monokai's video. Detect standalone-ness on the document with template content stripped, mount the mirrored install-layout copy so a block's own `../assets/*` references resolve, and capture posters opaque: `format: "png"` is the engine's transparent mode and forces `background-image: none` on every composition root, which erased the desktop backdrop these blocks paint. Publishing gets the missing half too: preview URLs are stable and the objects are uploaded `immutable` with a one-year max-age, so a re-upload alone never reaches a reader. * fix(scripts): install ffmpeg in the preview job and fix the sibling renderer The canary this PR added caught its own regression: the poster transcode shells out to ffmpeg, which ubuntu-latest does not ship and this job never needed, so both canaries failed with `spawnSync ffmpeg ENOENT`. Install it the way every other render job does. `encodeForWeb` has always shelled out to the same binary; the job only got away with it because `--skip-video` skipped that path. generate-template-previews.ts captures posters through the same transparent `format: "png"` mode, so any template painting its own backdrop loses it exactly as the code snippets did. Fixing one renderer and leaving its sibling on the broken call would just move the bug. Also fold the three separate parses of registry-item.json into one read: they had drifted into three different failure behaviours for the same file.
228 lines
7.1 KiB
TypeScript
228 lines
7.1 KiB
TypeScript
#!/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/<id>.png + <id>.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 { execFileSync } from "node:child_process";
|
|
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<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 {
|
|
// Opaque capture, for the reason spelled out in generate-catalog-previews.ts:
|
|
// `format: "png"` is the engine's TRANSPARENT mode and forces
|
|
// `background-image: none !important` on every composition root, silently
|
|
// dropping any backdrop the template paints for itself.
|
|
const session = await createCaptureSession(fileServer.url, framesDir, {
|
|
width: config.width,
|
|
height: config.height,
|
|
fps: 30,
|
|
format: "jpeg",
|
|
quality: 95,
|
|
});
|
|
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);
|
|
execFileSync(
|
|
"ffmpeg",
|
|
["-v", "error", "-y", "-i", result.path, join(outputDir, `${templateId}.png`)],
|
|
{ stdio: "inherit" },
|
|
);
|
|
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);
|
|
});
|