mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(registry): seed transition blocks — 14 shader + 14 CSS showcase (#270)
## What Add 28 transition blocks from the Hyperframe Template Structure catalog, bringing the registry to 53 total items. ### Shader transitions (14 blocks, WebGL, 4s each) `domain-warp-dissolve`, `ridged-burn`, `whip-pan`, `sdf-iris`, `ripple-waves`, `gravitational-lens`, `cinematic-zoom`, `chromatic-radial-split`, `glitch`, `swirl-vortex`, `thermal-distortion`, `flash-through-white`, `cross-warp-morph`, `light-leak` ### CSS transition showcases (14 blocks, various durations) `transitions-3d`, `transitions-blur`, `transitions-cover`, `transitions-destruction`, `transitions-dissolve`, `transitions-distortion`, `transitions-grid`, `transitions-light`, `transitions-mechanical`, `transitions-other`, `transitions-push`, `transitions-radial`, `transitions-scale`, `transitions-shader` ## Why Phase D content accumulation. Transitions are the most-requested category for the catalog. ## How - Shader transitions extracted from `shader-showcase.zip`, each a standalone HTML with WebGL shaders - CSS transitions extracted from `showcase-bundle.zip`, each a standalone showcase page - All tagged with `transition` + `shader` or `showcase` for catalog grouping - Preview thumbnails generated for all 28 blocks - Catalog pages + index regenerated ## Test plan - [x] All 28 blocks produce preview thumbnails - [x] `registry-item.json` validates for all blocks - [x] Catalog pages generated (45 total items in catalog-index.json) - [x] `oxfmt --check` passes
This commit is contained in:
@@ -61,7 +61,13 @@ function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
|
||||
const manifestPath = join(dir, entry.name, "registry-item.json");
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
|
||||
let manifest: RegistryItem;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Skipping ${manifestPath}: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
items.push({ kind, manifest });
|
||||
}
|
||||
}
|
||||
@@ -100,10 +106,10 @@ function generateItemMdx(kind: ItemKind, manifest: RegistryItem): string {
|
||||
lines.push(tagBadges, "");
|
||||
}
|
||||
|
||||
// Preview video with poster fallback — matches the examples page pattern.
|
||||
// Preview video with poster — muted loop, no autoPlay (matches examples page).
|
||||
const previewPath = `/images/catalog/${typeDir(kind)}/${manifest.name}`;
|
||||
lines.push(
|
||||
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" muted loop playsInline autoPlay />`,
|
||||
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" autoPlay muted loop playsInline />`,
|
||||
"",
|
||||
);
|
||||
|
||||
@@ -235,39 +241,69 @@ function main(): void {
|
||||
// Update docs.json navigation with generated catalog pages.
|
||||
const docsJsonPath = join(docsDir, "docs.json");
|
||||
const docsJson = JSON.parse(readFileSync(docsJsonPath, "utf-8"));
|
||||
const tabs = docsJson.navigation?.tabs as Array<{ tab: string; groups: unknown[] }>;
|
||||
const tabs = docsJson.navigation?.tabs;
|
||||
if (!Array.isArray(tabs)) {
|
||||
console.warn(" ⚠ docs.json has no navigation.tabs — skipping nav update");
|
||||
console.log("\nDone.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build catalog groups from discovered items
|
||||
const blockPages = catalogIndex
|
||||
.filter((i) => i.type === "block")
|
||||
.map((i) => `catalog/blocks/${i.name}`);
|
||||
const componentPages = catalogIndex
|
||||
.filter((i) => i.type === "component")
|
||||
.map((i) => `catalog/components/${i.name}`);
|
||||
// Build catalog groups by category (first tag), like shadcn/ui.
|
||||
// Items with the same first tag are grouped together. Items without tags
|
||||
// go into an "Other" group. Groups are sorted with a priority order.
|
||||
const GROUP_ORDER: Record<string, number> = {
|
||||
"Social Overlays": 0,
|
||||
"Shader Transitions": 1,
|
||||
"CSS Transitions": 2,
|
||||
Showcases: 3,
|
||||
Data: 4,
|
||||
Effects: 5,
|
||||
Blocks: 6,
|
||||
};
|
||||
|
||||
const catalogGroups: { group: string; pages: string[] }[] = [];
|
||||
if (blockPages.length > 0) catalogGroups.push({ group: "Blocks", pages: blockPages });
|
||||
if (componentPages.length > 0) catalogGroups.push({ group: "Components", pages: componentPages });
|
||||
function groupForItem(entry: CatalogEntry): string {
|
||||
const tags = entry.tags;
|
||||
// Two-tag combos for specific grouping
|
||||
if (tags.includes("transition") && tags.includes("shader")) return "Shader Transitions";
|
||||
if (tags.includes("transition") && tags.includes("showcase")) return "CSS Transitions";
|
||||
// Single-tag mapping
|
||||
if (tags.includes("social")) return "Social Overlays";
|
||||
if (tags.includes("transition"))
|
||||
return entry.type === "component" ? "Effects" : "CSS Transitions";
|
||||
if (tags.includes("showcase") || tags.includes("3d")) return "Showcases";
|
||||
if (tags.includes("data") || tags.includes("chart") || tags.includes("ascii")) return "Data";
|
||||
if (entry.type === "component") return "Effects";
|
||||
// Remaining blocks
|
||||
return "Blocks";
|
||||
}
|
||||
|
||||
const groupMap = new Map<string, string[]>();
|
||||
for (const entry of catalogIndex) {
|
||||
const group = groupForItem(entry);
|
||||
const dir = entry.type === "block" ? "blocks" : "components";
|
||||
const page = `catalog/${dir}/${entry.name}`;
|
||||
if (!groupMap.has(group)) groupMap.set(group, []);
|
||||
groupMap.get(group)!.push(page);
|
||||
}
|
||||
|
||||
const catalogGroups = [...groupMap.entries()]
|
||||
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 50) - (GROUP_ORDER[b] ?? 50))
|
||||
.map(([group, pages]) => ({ group, pages }));
|
||||
|
||||
if (catalogGroups.length > 0) {
|
||||
// Replace or insert the Catalog tab
|
||||
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
|
||||
const catalogTab = { tab: "Catalog", groups: catalogGroups };
|
||||
// Remove existing Catalog tab if present, then insert at position 1
|
||||
// (after Documentation, before Packages).
|
||||
if (existingIdx >= 0) {
|
||||
tabs[existingIdx] = catalogTab;
|
||||
} else {
|
||||
// Insert before the last tab (Reference)
|
||||
const refIdx = tabs.findIndex((t) => t.tab === "Reference");
|
||||
if (refIdx >= 0) {
|
||||
tabs.splice(refIdx, 0, catalogTab);
|
||||
} else {
|
||||
tabs.push(catalogTab);
|
||||
}
|
||||
tabs.splice(existingIdx, 1);
|
||||
}
|
||||
const docsIdx = tabs.findIndex((t) => t.tab === "Documentation");
|
||||
tabs.splice(docsIdx >= 0 ? docsIdx + 1 : 1, 0, catalogTab);
|
||||
writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + "\n", "utf-8");
|
||||
console.log(
|
||||
` ✓ docs.json updated with ${blockPages.length} blocks + ${componentPages.length} components`,
|
||||
);
|
||||
const totalPages = catalogGroups.reduce((n, g) => n + g.pages.length, 0);
|
||||
console.log(` ✓ docs.json updated with ${catalogGroups.length} groups, ${totalPages} pages`);
|
||||
}
|
||||
|
||||
console.log("\nDone.");
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
/**
|
||||
* Generate Catalog Preview Images + Videos
|
||||
*
|
||||
* Extends the template preview pipeline to handle all registry item types:
|
||||
* - Examples: renders index.html (same as generate-template-previews.ts)
|
||||
* - Blocks: renders the block's standalone HTML directly
|
||||
* - Components: renders the component's demo.html
|
||||
* 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
|
||||
*
|
||||
@@ -127,7 +128,49 @@ function prepareProjectDir(item: CatalogItem): string {
|
||||
|
||||
// The HyperFrames producer navigates to index.html at the project root.
|
||||
// Blocks and component demos are standalone HTML files, not index.html.
|
||||
// Create a wrapper index.html that loads the entry file as a sub-composition.
|
||||
// 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;
|
||||
@@ -140,13 +183,24 @@ function prepareProjectDir(item: CatalogItem): string {
|
||||
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; }</style>
|
||||
<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}">
|
||||
@@ -168,17 +222,16 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
|
||||
const outDir = outputDir(item.kind);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Read dimensions from registry-item.json or default to 1920x1080
|
||||
// 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 manifestPath = join(item.sourceDir, "registry-item.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
if (manifest.dimensions) {
|
||||
width = manifest.dimensions.width ?? width;
|
||||
height = manifest.dimensions.height ?? height;
|
||||
}
|
||||
}
|
||||
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 });
|
||||
@@ -201,7 +254,9 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
|
||||
}
|
||||
|
||||
// Capture at 40% of duration for a representative frame
|
||||
const captureTime = Math.min(2.0, duration * 0.4);
|
||||
// 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)`);
|
||||
|
||||
Reference in New Issue
Block a user