Skip to content

Commit ea6f949

Browse files
authored
ci: render catalog previews on PR (heygen-com#262)
## What CI workflow that auto-renders preview thumbnails for new/changed registry blocks and components on pull requests. **New files:** - `scripts/generate-catalog-previews.ts` — catalog preview renderer supporting all three registry item types - `.github/workflows/catalog-previews.yml` — GitHub Actions workflow triggered on PRs touching `registry/blocks/` or `registry/components/` ## Why Phase B of the catalog plan (PR 8). After this lands, future block/component PRs don't need to manually generate preview images — CI handles it automatically. ## How The preview script discovers items from the registry directory structure: - **Examples**: renders `index.html` (same as the existing `generate-template-previews.ts`) - **Blocks**: renders the block's standalone HTML file directly (e.g., `data-chart.html`) - **Components**: renders the component's `demo.html` (the demo.html convention from PR 7) The CI workflow: 1. Detects which blocks/components changed in the PR via `git diff` 2. Renders thumbnails for only the changed items (not the full catalog) 3. Uploads preview PNGs as artifacts Output goes to `docs/images/catalog/<type>/<name>.{png,mp4}` (separate from the existing `docs/images/templates/` directory). Supports CLI flags: `--only <name>`, `--type <example|block|component>`, `--skip-video`. ## Test plan - [x] Script compiles and passes typecheck (`lefthook pre-commit` ran lint + typecheck + format) - [x] Workflow YAML is valid (standard GitHub Actions syntax, follows existing ci.yml patterns) - [ ] Full end-to-end test requires Chrome + FFmpeg (runs in CI, not testable locally without producer deps)
1 parent b0f754a commit ea6f949

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Catalog Previews
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "registry/blocks/**"
7+
- "registry/components/**"
8+
- "scripts/generate-catalog-previews.ts"
9+
10+
concurrency:
11+
group: catalog-previews-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
render-previews:
16+
name: Render catalog previews
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 30
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: oven-sh/setup-bun@v2
23+
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: 22
27+
28+
- run: bun install --frozen-lockfile
29+
30+
- run: bun run build
31+
32+
# Chrome headless shell for rendering
33+
- uses: browser-actions/setup-chrome@v1
34+
with:
35+
chrome-version: stable
36+
37+
# FFmpeg for video encoding
38+
- uses: FedericoCarboni/setup-ffmpeg@v3
39+
40+
- name: Render changed block/component previews
41+
continue-on-error: true
42+
run: |
43+
# Find which blocks/components changed in this PR
44+
CHANGED_ITEMS=$(git diff --name-only origin/main...HEAD -- registry/blocks/ registry/components/ \
45+
| grep -oP '(?<=registry/(blocks|components)/)[^/]+' \
46+
| sort -u)
47+
48+
if [ -z "$CHANGED_ITEMS" ]; then
49+
echo "No block/component changes detected."
50+
exit 0
51+
fi
52+
53+
echo "Changed items: $CHANGED_ITEMS"
54+
FAILED=0
55+
56+
for item in $CHANGED_ITEMS; do
57+
echo "Rendering preview for: $item"
58+
if ! npx tsx scripts/generate-catalog-previews.ts --only "$item" --skip-video; then
59+
echo "::warning::Failed to render preview for $item"
60+
FAILED=$((FAILED + 1))
61+
fi
62+
done
63+
64+
if [ "$FAILED" -gt 0 ]; then
65+
echo "::warning::$FAILED item(s) failed to render"
66+
exit 1
67+
fi
68+
69+
- name: Upload preview artifacts
70+
if: always()
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: catalog-previews
74+
path: docs/images/catalog/
75+
if-no-files-found: ignore
76+
retention-days: 30
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)