85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Generate Catalog Preview Images + Videos
|
|
*
|
|
* Renders preview thumbnails and videos for registry blocks and components.
|
|
* Examples use the separate generate-template-previews.ts script.
|
|
*
|
|
* - Blocks: renders the block's standalone HTML via a wrapper index.html
|
|
* - Components: renders the component's demo.html via a wrapper index.html
|
|
*
|
|
* Output: docs/images/catalog/<type>/<name>.png + <name>.mp4
|
|
* (docs/images/ is gitignored — files are served from the CDN. After running
|
|
* this script, run `bun run upload:docs-images` to publish.)
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/generate-catalog-previews.ts # all items
|
|
* npx tsx scripts/generate-catalog-previews.ts --only data-chart # single item
|
|
* npx tsx scripts/generate-catalog-previews.ts --type block # blocks only
|
|
* npx tsx scripts/generate-catalog-previews.ts --skip-video # thumbnails only
|
|
*/
|
|
|
|
import {
|
|
readdirSync,
|
|
readFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
cpSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { join, resolve, dirname } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
|
|
import {
|
|
createFileServer,
|
|
createCaptureSession,
|
|
initializeSession,
|
|
captureFrame,
|
|
getCompositionDuration,
|
|
closeCaptureSession,
|
|
createRenderJob,
|
|
executeRenderJob,
|
|
} from "../packages/producer/src/index.js";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(scriptDir, "..");
|
|
const registryDir = resolve(repoRoot, "registry");
|
|
|
|
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
|
|
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
|
|
repoRoot,
|
|
"packages/core/dist/hyperframe.manifest.json",
|
|
);
|
|
}
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
type ItemKind = "block" | "component";
|
|
|
|
interface CatalogItem {
|
|
name: string;
|
|
kind: ItemKind;
|
|
/** Directory containing the item's files in the registry. */
|
|
sourceDir: string;
|
|
/** The HTML file to render (relative to sourceDir). */
|
|
entryFile: string;
|
|
}
|
|
|
|
// ── Discovery ──────────────────────────────────────────────────────────────
|
|
|
|
function discoverItems(kindFilter: ItemKind | null, nameFilter: string | null): CatalogItem[] {
|
|
const items: CatalogItem[] = [];
|
|
|
|
// Blocks and components only — examples use the existing generate-template-previews.ts.
|
|
const kinds: { kind: ItemKind; dir: string }[] = [
|
|
{ kind: "block", dir: join(registryDir, "blocks") },
|
|
{ kind: "component", dir: join(registryDir, "components") },
|
|
];
|
|
|
|
for (const { kind, dir } of kinds) {
|
|
if (kindFilter && kindFilter !== kind) continue;
|
|
if (!existsSync(dir)) continue;
|
|
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (!e.isDirectory()) continue;
|
|
if (nameFilter && e.name !== nameFilter) continue;
|
|
|
|
const sourceDir = join(dir, e.name);
|
|
const manifestPath = join(sourceDir, "registry-item.json");
|
|
if (!existsSync(manifestPath)) continue;
|
|
|
|
// Blocks: find the first composition file. Components: use demo.html.
|
|
let entryFile: string;
|
|
if (kind === "component") {
|
|
entryFile = "demo.html";
|
|
} else {
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
const compFile = manifest.files?.find(
|
|
(f: { type: string }) => f.type === "hyperframes:composition",
|
|
);
|
|
entryFile = compFile?.path ?? `${e.name}.html`;
|
|
}
|
|
|
|
if (!existsSync(join(sourceDir, entryFile))) continue;
|
|
items.push({ name: e.name, kind, sourceDir, entryFile });
|
|
}
|
|
}
|
|
|
|
if (nameFilter && items.length === 0) {
|
|
const allNames = discoverItems(null, null).map((i) => i.name);
|
|
console.error(`Item "${nameFilter}" not found. Available: ${allNames.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// ── Preview generation ─────────────────────────────────────────────────────
|
|
|
|
function outputDir(kind: ItemKind): string {
|
|
const typeDir = kind === "block" ? "blocks" : "components";
|
|
return resolve(repoRoot, "docs/images/catalog", typeDir);
|
|
}
|
|
|
|
function prepareProjectDir(item: CatalogItem): string {
|
|
const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
cpSync(item.sourceDir, tmpDir, { recursive: true });
|
|
|
|
// The HyperFrames producer navigates to index.html at the project root.
|
|
// Blocks and component demos are standalone HTML files, not index.html.
|
|
// If the entry file is a standalone HTML (has its own timeline registration),
|
|
// just rename it to index.html. Otherwise create a wrapper.
|
|
if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) {
|
|
const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8");
|
|
const hasTimeline = entryContent.includes("__timelines");
|
|
if (hasTimeline) {
|
|
// Standalone block — copy to index.html and render directly.
|
|
// For social overlays with transparent backgrounds, inject a dark bg
|
|
// so the overlay card is visible against something.
|
|
let content = entryContent;
|
|
const hasSocialTag = (() => {
|
|
try {
|
|
const m = JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
|
|
return (m.tags ?? []).includes("social");
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
if (hasSocialTag) {
|
|
// Dark bg for transparent overlays
|
|
if (content.includes("background: transparent")) {
|
|
content = content.replace("background: transparent", "background: #1a1a2e");
|
|
}
|
|
// Reposition bottom-anchored overlays to center for preview.
|
|
// Social overlays use "bottom: Npx" positioning — replace with
|
|
// "top: 50%; transform: translate(-50%, -50%)" for a centered preview.
|
|
content = content.replace(
|
|
/bottom:\s*\d+px;\s*\n(\s*)left:\s*50%;\s*\n(\s*)transform:\s*translateX\(-50%\)/,
|
|
"top: 50%;\n$1left: 50%;\n$2transform: translate(-50%, -50%)",
|
|
);
|
|
// Scale down large centered cards (like Spotify) that use
|
|
// margin-based centering with large negative margins.
|
|
if (/margin-top:\s*-[3-9]\d\dpx/.test(content)) {
|
|
content = content.replace(
|
|
/(<body[^>]*>)/,
|
|
"$1\n<style>body { transform: scale(0.55); transform-origin: center center; }</style>",
|
|
);
|
|
}
|
|
}
|
|
writeFileSync(join(tmpDir, "index.html"), content, "utf-8");
|
|
return tmpDir;
|
|
}
|
|
}
|
|
if (!existsSync(join(tmpDir, "index.html"))) {
|
|
const manifestPath = join(tmpDir, "registry-item.json");
|
|
let width = 1920;
|
|
let height = 1080;
|
|
let duration = 5;
|
|
if (existsSync(manifestPath)) {
|
|
const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
width = m.dimensions?.width ?? width;
|
|
height = m.dimensions?.height ?? height;
|
|
duration = m.duration ?? duration;
|
|
}
|
|
|
|
// Dark background for social overlays so transparent cards are visible.
|
|
const tags: string[] = (() => {
|
|
try {
|
|
return JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8")).tags ?? [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})();
|
|
const isSocialOverlay = tags.includes("social") || tags.includes("overlay");
|
|
const bgColor = isSocialOverlay ? "#1a1a2e" : "#ffffff";
|
|
|
|
const wrapper = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=${width}, height=${height}" />
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
|
<style>* { margin: 0; padding: 0; } html, body { width: ${width}px; height: ${height}px; overflow: hidden; background: ${bgColor}; }</style>
|
|
</head>
|
|
<body>
|
|
<div data-composition-id="preview-root" data-width="${width}" data-height="${height}" data-start="0" data-duration="${duration}">
|
|
<div data-composition-id="${item.name}" data-composition-src="${item.entryFile}" data-start="0" data-duration="${duration}" data-track-index="0" data-width="${width}" data-height="${height}"></div>
|
|
</div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines["preview-root"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
writeFileSync(join(tmpDir, "index.html"), wrapper, "utf-8");
|
|
}
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
async function generateThumbnail(item: CatalogItem, projectDir: string): Promise<void> {
|
|
const outDir = outputDir(item.kind);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
// Read dimensions from the wrapper index.html (which may differ from native
|
|
// dimensions for portrait overlays that are scaled to fit landscape).
|
|
let width = 1920;
|
|
let height = 1080;
|
|
const wrapperPath = join(projectDir, "index.html");
|
|
const wrapperHtml = readFileSync(wrapperPath, "utf-8");
|
|
const wMatch = wrapperHtml.match(/data-width="(\d+)"/);
|
|
const hMatch = wrapperHtml.match(/data-height="(\d+)"/);
|
|
if (wMatch) width = parseInt(wMatch[1], 10);
|
|
if (hMatch) height = parseInt(hMatch[1], 10);
|
|
|
|
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,
|
|
height,
|
|
fps: { num: 30, den: 1 },
|
|
format: "png",
|
|
});
|
|
await initializeSession(session);
|
|
|
|
let duration: number;
|
|
try {
|
|
duration = await getCompositionDuration(session);
|
|
} catch {
|
|
duration = 5;
|
|
}
|
|
|
|
// Capture at 40% of duration for a representative frame
|
|
// Capture at 60% of duration so the animation is well underway.
|
|
// Cap at 3s to avoid overly-late captures on long compositions.
|
|
const captureTime = Math.min(3.0, duration * 0.6);
|
|
const result = await captureFrame(session, 0, captureTime);
|
|
cpSync(result.path, join(outDir, `${item.name}.png`));
|
|
console.log(` ✓ ${item.name}.png (${result.captureTimeMs}ms)`);
|
|
|
|
await closeCaptureSession(session);
|
|
} finally {
|
|
fileServer.close();
|
|
rmSync(framesDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function generateVideo(item: CatalogItem, projectDir: string): Promise<void> {
|
|
const outDir = outputDir(item.kind);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
const outMp4 = join(outDir, `${item.name}.mp4`);
|
|
const job = createRenderJob({
|
|
fps: { num: 24, den: 1 },
|
|
quality: "draft",
|
|
format: "mp4",
|
|
});
|
|
await executeRenderJob(job, projectDir, outMp4);
|
|
console.log(` ✓ ${item.name}.mp4`);
|
|
}
|
|
|
|
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
|
|
function parseArgs(): { only: string | null; type: ItemKind | null; skipVideo: boolean } {
|
|
let only: string | null = null;
|
|
let type: ItemKind | null = null;
|
|
let skipVideo = false;
|
|
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const arg = process.argv[i];
|
|
if (arg === "--only" && process.argv[i + 1]) {
|
|
i++;
|
|
only = process.argv[i] ?? null;
|
|
}
|
|
if (arg === "--type" && process.argv[i + 1]) {
|
|
i++;
|
|
const val = process.argv[i];
|
|
if (val === "block" || val === "component") {
|
|
type = val;
|
|
} else {
|
|
console.error(`Invalid --type: "${val}". Must be block or component.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
if (arg === "--skip-video") skipVideo = true;
|
|
}
|
|
|
|
return { only, type, skipVideo };
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const { only, type, skipVideo } = parseArgs();
|
|
const items = discoverItems(type, only);
|
|
|
|
console.log(
|
|
`Generating catalog previews for ${items.length} item(s)${skipVideo ? " (thumbnails only)" : " + videos"}...\n`,
|
|
);
|
|
|
|
for (const item of items) {
|
|
console.log(`[${item.kind}] ${item.name}`);
|
|
const projectDir = prepareProjectDir(item);
|
|
try {
|
|
await generateThumbnail(item, projectDir);
|
|
if (!skipVideo) {
|
|
await generateVideo(item, projectDir);
|
|
}
|
|
} catch (err) {
|
|
console.error(` ✗ ${item.name}: ${err instanceof Error ? err.message : err}`);
|
|
} finally {
|
|
rmSync(projectDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log("\nDone.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|