feat(docs): add template gallery page with visual previews (#160)

* feat(docs): add template gallery page with visual previews

* fix(docs): remove invalid MDX heading anchors

* chore: retrigger CI

* feat(docs): merge gallery into templates page with hover-to-play video previews

- Consolidated gallery.mdx and templates.mdx into single templates.mdx
- Moved templates page to Getting Started section
- Added MP4 video previews rendered by hyperframes (hover to play)
- Custom JS for hover-to-play behavior (Mintlify strips JSX event handlers)
- 2-column grid for landscape, 3-column for portrait
- Remotion-style cards with gradient overlay labels

* fix(docs): update broken links after templates page move

* ci(regression): remove scripts/ from regression trigger paths

scripts/ contains dev utilities (lint, versioning, preview generation)
that don't affect the rendering engine.
This commit is contained in:
James Russo
2026-03-31 13:04:04 -07:00
committed by GitHub
parent 5bb74cc0fc
commit fee51f7a65
20 changed files with 449 additions and 293 deletions
-1
View File
@@ -23,7 +23,6 @@ jobs:
- "packages/core/**"
- "packages/producer/**"
- "packages/engine/**"
- "scripts/**"
- "Dockerfile*"
regression-shards:
+2 -2
View File
@@ -133,7 +133,7 @@ Compositions can expose variables for dynamic content:
<div data-composition-id="card" data-var-title="string" data-var-color="color">
```
Variables make compositions reusable as [templates](/guides/templates) -- the same composition can render different content by injecting variable values at render time.
Variables make compositions reusable as [templates](/templates) -- the same composition can render different content by injecting variable values at render time.
## Listing Compositions
@@ -152,7 +152,7 @@ npx hyperframes compositions
<Card title="GSAP Animation" icon="wand-magic-sparkles" href="/guides/gsap-animation">
Add animations to your compositions with GSAP timelines
</Card>
<Card title="Templates" icon="grid-2" href="/guides/templates">
<Card title="Templates" icon="grid-2" href="/templates">
Start from built-in templates for common video patterns
</Card>
<Card title="HTML Schema Reference" icon="book" href="/reference/html-schema">
+1 -2
View File
@@ -36,7 +36,7 @@
"groups": [
{
"group": "Getting Started",
"pages": ["introduction", "quickstart"]
"pages": ["introduction", "quickstart", "templates"]
},
{
"group": "Concepts",
@@ -51,7 +51,6 @@
"group": "Guides",
"pages": [
"guides/gsap-animation",
"guides/templates",
"guides/rendering",
"guides/common-mistakes",
"guides/troubleshooting"
-215
View File
@@ -1,215 +0,0 @@
---
title: Templates
description: "Built-in templates for common video patterns."
---
Hyperframes includes starter templates to help you scaffold compositions quickly. Each template gives you a working project with the correct [composition structure](/concepts/compositions), [data attributes](/concepts/data-attributes), and a [GSAP timeline](/guides/gsap-animation) already wired up.
## Using Templates
```bash Terminal
npx hyperframes init --template <name>
```
This creates a new project directory with an `index.html` composition, sub-compositions, and any required assets.
You can also run `npx hyperframes init` without `--template` to choose interactively.
## Available Templates
<Tabs>
<Tab title="blank">
### blank
An empty composition with just the scaffolding — a video element, audio track, captions sub-composition, and an empty GSAP timeline. Start from scratch.
**What it produces:** A minimal project with a single video clip and audio, ready for you to customize. The timeline is registered and ready for you to add tweens.
**When to use it:** You have a specific design in mind and want full control. Good for AI agent workflows that will generate the composition programmatically, or when starting from a source video.
```bash Terminal
npx hyperframes init my-video --template blank
```
**What you get:**
```
my-video/
├── meta.json
├── index.html
└── compositions/
└── captions.html
```
</Tab>
<Tab title="warm-grain">
### warm-grain
A cream-toned aesthetic with grain texture overlay. Warm, organic feel suited for lifestyle and branding videos.
**What it produces:** A composition with warm color grading, textured grain, and smooth transitions. Includes an intro sub-composition and captions support.
**When to use it:** You want an organic, stylized look for branding, lifestyle, or editorial content.
```bash Terminal
npx hyperframes init my-video --template warm-grain
```
**What you get:**
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── graphics.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="play-mode">
### play-mode
Playful elastic animations with bold, energetic motion.
**What it produces:** A composition with bouncy, elastic animation curves and dynamic layout transitions. Includes intro, stats, and captions sub-compositions.
**When to use it:** You want a fun, high-energy feel — great for social media, product launches, or explainer videos.
```bash Terminal
npx hyperframes init my-video --template play-mode
```
**What you get:**
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── stats.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="swiss-grid">
### swiss-grid
Structured grid layout inspired by Swiss/International Typographic Style.
**What it produces:** A clean, grid-based composition with precise typography and structured layouts. Includes intro, graphics, and captions sub-compositions.
**When to use it:** You want a clean, professional, information-dense layout — ideal for corporate videos, data presentations, or technical content.
```bash Terminal
npx hyperframes init my-video --template swiss-grid
```
**What you get:**
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── graphics.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="vignelli">
### vignelli
Bold typography with red accents, inspired by Massimo Vignelli's design philosophy.
**What it produces:** A striking composition with strong typographic hierarchy, red accent colors, and confident transitions. Includes overlays and captions sub-compositions.
**When to use it:** You want a bold, authoritative visual style — great for headlines, announcements, or editorial content.
```bash Terminal
npx hyperframes init my-video --template vignelli
```
**What you get:**
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── overlays.html
│ └── captions.html
└── assets/
```
</Tab>
</Tabs>
## Choosing a Template
| Template | Style | Best for |
|----------|-------|----------|
| `blank` | Minimal scaffolding | Full control, agent-generated, starting from video |
| `warm-grain` | Organic, textured | Lifestyle, branding, editorial |
| `play-mode` | Energetic, elastic | Social media, product launches |
| `swiss-grid` | Clean, structured | Corporate, data, technical |
| `vignelli` | Bold, typographic | Headlines, announcements |
<Tip>
If you are new to Hyperframes, start with `warm-grain` or `play-mode` to see working animations and sub-compositions in action. Use `blank` when you want minimal scaffolding and full control. Run `npx hyperframes init` without `--template` to preview all options interactively.
</Tip>
## Passing a Source Video
You can initialize a project with your own video file using the `--video` flag:
```bash Terminal
npx hyperframes init my-video --template warm-grain --video ./my-clip.mp4
```
The CLI will probe the video for duration, resolution, and codec. If the video uses an incompatible codec (not H.264, VP8/9, AV1, or Theora), it will be automatically transcoded to H.264 MP4 if FFmpeg is available.
## Custom Templates
Any directory with an `index.html` can serve as a template. You can copy a directory manually or build your own init workflow.
Your custom template needs:
1. An `index.html` with a [`data-composition-id`](/concepts/data-attributes#composition-attributes) root element
2. A [GSAP timeline](/guides/gsap-animation) registered in `window.__timelines`
3. Any assets in the same directory or a subdirectory
```html index.html
<div id="root" data-composition-id="my-template"
data-start="0" data-width="1920" data-height="1080">
<!-- Your elements here -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const tl = gsap.timeline({ paused: true });
// Add your animations...
window.__timelines = window.__timelines || {};
window.__timelines["my-template"] = tl;
</script>
</div>
```
After creating a custom template, validate it with the [linter](/packages/cli#lint):
```bash Terminal
npx hyperframes lint
```
## Next Steps
<CardGroup cols={2}>
<Card title="Quickstart" icon="rocket" href="/quickstart">
Create, preview, and render your first video
</Card>
<Card title="GSAP Animation" icon="wand-magic-sparkles" href="/guides/gsap-animation">
Add animations to your template
</Card>
<Card title="Compositions" icon="layer-group" href="/concepts/compositions">
Understand the composition data model
</Card>
<Card title="Rendering" icon="film" href="/guides/rendering">
Render your composition to MP4
</Card>
</CardGroup>
+1 -1
View File
@@ -9,7 +9,7 @@ If your issue is about a specific coding mistake (animations not working, video
<Accordion title='"No composition found"'>
Your directory needs an `index.html` with a valid [composition](/concepts/compositions). The root element must have a [`data-composition-id`](/concepts/data-attributes#composition-attributes) attribute.
**Fix:** Run `npx hyperframes init` to create a composition from a [template](/guides/templates), or verify your `index.html` has the correct structure:
**Fix:** Run `npx hyperframes init` to create a composition from a [template](/templates), or verify your `index.html` has the correct structure:
```html index.html
<div id="root" data-composition-id="my-video"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -100,7 +100,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
```bash
npx hyperframes init my-video --template warm-grain
```
See [Templates](/guides/templates) for all available templates.
See [Templates](/templates) for all available templates.
</Step>
<Step title="Preview in browser">
Start the development server with live hot reload:
@@ -172,7 +172,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
After scaffolding, the CLI installs AI coding skills for Claude Code, Gemini CLI, and Codex CLI (use `--skip-skills` to disable). See [`skills`](#skills) command.
See [Templates](/guides/templates) for full details.
See [Templates](/templates) for full details.
### `compositions`
+2 -2
View File
@@ -68,7 +68,7 @@ A 1920x1080 video with an animated title that fades in from above — rendered t
npx hyperframes init my-video --non-interactive --template blank
```
See [Templates](/guides/templates) for all available templates.
See [Templates](/templates) for all available templates.
This generates a project structure like:
@@ -183,7 +183,7 @@ A 1920x1080 video with an animated title that fades in from above — rendered t
<Card title="GSAP Animation" icon="wand-magic-sparkles" href="/guides/gsap-animation">
Add fade, slide, scale, and custom animations to your videos
</Card>
<Card title="Templates" icon="grid-2" href="/guides/templates">
<Card title="Templates" icon="grid-2" href="/templates">
Start from built-in templates like Warm Grain and Swiss Grid
</Card>
<Card title="Rendering" icon="film" href="/guides/rendering">
+59
View File
@@ -0,0 +1,59 @@
export function TemplateCard({ id, title, description, href, portrait }) {
const [hovering, setHovering] = React.useState(false);
const imgSrc = `/images/templates/${id}.png`;
const videoSrc = `/images/templates/${id}.mp4`;
return (
<a
href={href}
className="not-prose group block rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-shadow hover:shadow-lg no-underline"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div
className="relative overflow-hidden bg-gray-100 dark:bg-gray-800"
style={{ aspectRatio: portrait ? "9/16" : "16/9" }}
>
<img
src={imgSrc}
alt={`${title} template`}
className="absolute inset-0 w-full h-full object-cover"
style={{
opacity: hovering ? 0 : 1,
transition: "opacity 0.2s ease",
}}
/>
{hovering && (
<video
src={videoSrc}
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
<div className="p-4">
<h3 className="text-base font-semibold text-gray-900 dark:text-white m-0">
{title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-0">
{description}
</p>
</div>
</a>
);
}
export function TemplateGrid({ children }) {
return (
<div
className="not-prose grid gap-4"
style={{ gridTemplateColumns: "repeat(2, 1fr)" }}
>
{children}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
.tpl-card video {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
display: block;
}
.tpl-card.portrait video {
aspect-ratio: 9/16;
}
.tpl-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
}
.tpl-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2.5rem 0.75rem 0.75rem;
background: linear-gradient(transparent, rgba(0,0,0,0.75));
pointer-events: none;
}
+27
View File
@@ -0,0 +1,27 @@
// Hover-to-play for template gallery video cards
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.tpl-card video').forEach(function (video) {
video.parentElement.addEventListener('mouseenter', function () {
video.play();
});
video.parentElement.addEventListener('mouseleave', function () {
video.pause();
video.currentTime = 2;
});
});
});
// Re-attach after client-side navigation (Mintlify uses SPA routing)
var observer = new MutationObserver(function () {
document.querySelectorAll('.tpl-card video:not([data-hover-bound])').forEach(function (video) {
video.setAttribute('data-hover-bound', 'true');
video.parentElement.addEventListener('mouseenter', function () {
video.play();
});
video.parentElement.addEventListener('mouseleave', function () {
video.pause();
video.currentTime = 2;
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
+270
View File
@@ -0,0 +1,270 @@
---
title: Templates
description: "Built-in templates for common video patterns. Hover to preview animations."
---
Hyperframes includes starter templates to help you scaffold compositions quickly. Each template gives you a working project with the correct [composition structure](/concepts/compositions), [data attributes](/concepts/data-attributes), and a [GSAP timeline](/guides/gsap-animation) already wired up.
```bash Terminal
npx hyperframes init my-video --template <name>
```
## Landscape Templates
<div className="not-prose grid grid-cols-2 gap-4 my-6">
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/warm-grain.mp4" poster="/images/templates/warm-grain.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.7))"}}><strong className="text-sm font-semibold text-white block">Warm Grain</strong><span className="text-xs text-zinc-300">Branding & lifestyle</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/play-mode.mp4" poster="/images/templates/play-mode.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Play Mode</strong><span className="text-xs text-zinc-300">Social media</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/swiss-grid.mp4" poster="/images/templates/swiss-grid.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Swiss Grid</strong><span className="text-xs text-zinc-300">Corporate & technical</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/kinetic-type.mp4" poster="/images/templates/kinetic-type.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Kinetic Type</strong><span className="text-xs text-zinc-300">Promos & title cards</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/decision-tree.mp4" poster="/images/templates/decision-tree.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Decision Tree</strong><span className="text-xs text-zinc-300">Explainers & tutorials</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/product-promo.mp4" poster="/images/templates/product-promo.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Product Promo</strong><span className="text-xs text-zinc-300">Product showcases</span></div>
</div>
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full aspect-video object-cover block" src="/images/templates/nyt-graph.mp4" poster="/images/templates/nyt-graph.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">NYT Graph</strong><span className="text-xs text-zinc-300">Data stories</span></div>
</div>
</div>
## Portrait Templates
<div className="not-prose grid grid-cols-3 gap-3 my-6">
<div className="tpl-card relative rounded-xl overflow-hidden bg-zinc-900 transition-transform hover:-translate-y-0.5 hover:shadow-xl">
<video className="w-full object-cover block" style={{aspectRatio: "9/16"}} src="/images/templates/vignelli.mp4" poster="/images/templates/vignelli.png" muted loop playsInline preload="metadata" />
<div className="absolute bottom-0 left-0 right-0 pt-6 pb-2 px-3" style={{background: "linear-gradient(transparent, rgba(0,0,0,0.75))"}}><strong className="text-sm font-semibold text-white block">Vignelli</strong><span className="text-xs text-zinc-300">Headlines & announcements</span></div>
</div>
</div>
<Tip>
Looking for a minimal starting point? Use **blank** — it gives you an empty composition with just the scaffolding, no visual design.
```bash Terminal
npx hyperframes init my-video --template blank
```
</Tip>
## Choosing a Template
| Template | Style | Format | Best for |
|----------|-------|--------|----------|
| `warm-grain` | Organic, textured | Landscape | Lifestyle, branding, editorial |
| `play-mode` | Energetic, elastic | Landscape | Social media, product launches |
| `swiss-grid` | Clean, structured | Landscape | Corporate, data, technical |
| `kinetic-type` | Dramatic type | Landscape | Promos, intros, title cards |
| `decision-tree` | Diagrammatic | Landscape | Explainers, tutorials |
| `product-promo` | Multi-scene | Landscape | Product showcases, demos |
| `nyt-graph` | Editorial data | Landscape | Data stories, reports |
| `vignelli` | Bold, typographic | Portrait | Headlines, announcements |
| `blank` | Minimal scaffolding | — | Full control, agent-generated |
## Template Details
<Tabs>
<Tab title="warm-grain">
### warm-grain
Cream-toned aesthetic with grain texture overlay.
**What it produces:** A composition with warm color grading, textured grain, and smooth transitions. Includes an intro sub-composition and captions support.
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── graphics.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="play-mode">
### play-mode
Playful elastic animations with bold, energetic motion.
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── stats.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="swiss-grid">
### swiss-grid
Structured grid layout inspired by Swiss/International Typographic Style.
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── intro.html
│ ├── graphics.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="vignelli">
### vignelli
Bold typography with red accents (1080×1920 portrait).
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── overlays.html
│ └── captions.html
└── assets/
```
</Tab>
<Tab title="kinetic-type">
### kinetic-type
Bold kinetic typography promo with dramatic text animations.
```
my-video/
├── meta.json
├── index.html
└── compositions/
└── main-graphics.html
```
</Tab>
<Tab title="decision-tree">
### decision-tree
Animated flowchart with branching paths and progressive reveal.
```
my-video/
├── meta.json
├── index.html
└── compositions/
└── decision_tree.html
```
</Tab>
<Tab title="product-promo">
### product-promo
Multi-scene product showcase with SVG assets.
```
my-video/
├── meta.json
├── index.html
├── compositions/
│ ├── scene1-logo-intro.html
│ ├── scene2-4-canvas.html
│ └── scene5-logo-outro.html
└── assets/
├── figma-cursors.svg
├── figma-logo-pieces.svg
└── figma-logo-pills.svg
```
</Tab>
<Tab title="nyt-graph">
### nyt-graph
Animated data chart in print editorial style.
```
my-video/
├── meta.json
├── index.html
└── compositions/
└── nyt-chart.html
```
</Tab>
<Tab title="blank">
### blank
Empty composition with just the scaffolding.
```
my-video/
├── meta.json
├── index.html
└── compositions/
└── captions.html
```
</Tab>
</Tabs>
## Passing a Source Video
```bash Terminal
npx hyperframes init my-video --template warm-grain --video ./my-clip.mp4
```
The CLI will probe the video for duration, resolution, and codec. If the video uses an incompatible codec, it will be automatically transcoded to H.264 MP4 if FFmpeg is available.
## Custom Templates
Any directory with an `index.html` can serve as a template. Your custom template needs:
1. An `index.html` with a [`data-composition-id`](/concepts/data-attributes#composition-attributes) root element
2. A [GSAP timeline](/guides/gsap-animation) registered in `window.__timelines`
3. Any assets in the same directory or a subdirectory
```html index.html
<div id="root" data-composition-id="my-template"
data-start="0" data-width="1920" data-height="1080">
<!-- Your elements here -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const tl = gsap.timeline({ paused: true });
// Add your animations...
window.__timelines = window.__timelines || {};
window.__timelines["my-template"] = tl;
</script>
</div>
```
After creating a custom template, validate it with the [linter](/packages/cli#lint):
```bash Terminal
npx hyperframes lint
```
## Next Steps
<CardGroup cols={2}>
<Card title="Quickstart" icon="rocket" href="/quickstart">
Create, preview, and render your first video
</Card>
<Card title="GSAP Animation" icon="wand-magic-sparkles" href="/guides/gsap-animation">
Add animations to your template
</Card>
<Card title="Compositions" icon="layer-group" href="/concepts/compositions">
Understand the composition data model
</Card>
<Card title="Rendering" icon="film" href="/guides/rendering">
Render your composition to MP4
</Card>
</CardGroup>
+60 -68
View File
@@ -1,13 +1,16 @@
#!/usr/bin/env tsx
/**
* Generate Template Preview Images
* Generate Template Preview Images + Videos
*
* Uses @hyperframes/producer to render PNG thumbnails of each built-in template.
* Output: docs/images/templates/<id>.png
* 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
*
* Usage:
* pnpm generate:previews # all templates
* pnpm generate:previews # all templates (PNG + MP4)
* pnpm generate:previews -- --only warm-grain
* pnpm generate:previews -- --skip-video # thumbnails only (faster)
*/
import {
@@ -29,6 +32,8 @@ import {
captureFrame,
getCompositionDuration,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "@hyperframes/producer";
const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -37,7 +42,6 @@ const bundledTemplatesDir = resolve(repoRoot, "packages/cli/src/templates");
const examplesDir = resolve(repoRoot, "examples");
const outputDir = resolve(repoRoot, "docs/images/templates");
// Point the producer at the monorepo's built core manifest
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
repoRoot,
@@ -45,25 +49,12 @@ if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
);
}
// Templates to skip — blank is just empty scaffolding (black screen without a video)
const SKIP_TEMPLATES = new Set(["blank"]);
// Template metadata: dimensions and best capture time for visual interest
const DEFAULT_CONFIG = { width: 1920, height: 1080, captureTime: 2.0 };
const TEMPLATE_CONFIG: Record<string, { width: number; height: number; captureTime: number }> = {
"warm-grain": { width: 1920, height: 1080, captureTime: 2.0 },
"play-mode": { width: 1920, height: 1080, captureTime: 2.0 },
"swiss-grid": { width: 1920, height: 1080, captureTime: 2.0 },
vignelli: { width: 1080, height: 1920, captureTime: 2.0 },
"decision-tree": { width: 1920, height: 1080, captureTime: 2.0 },
"kinetic-type": { width: 1920, height: 1080, captureTime: 2.0 },
"product-promo": { width: 1920, height: 1080, captureTime: 2.0 },
"nyt-graph": { width: 1920, height: 1080, captureTime: 2.0 },
};
/**
* Patch template HTML: remove __VIDEO_SRC__ placeholders and set duration.
* Same logic as init.ts patchVideoSrc() with no video file.
*/
function patchTemplateHtml(dir: string, durationSeconds: number): void {
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
@@ -71,7 +62,6 @@ function patchTemplateHtml(dir: string, durationSeconds: number): void {
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
// Remove video/audio elements with placeholder src
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, "");
@@ -82,23 +72,24 @@ function patchTemplateHtml(dir: string, durationSeconds: number): void {
}
}
function parseArgs(): { only: string | null } {
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 };
return { only, skipVideo };
}
/** Resolve the on-disk directory for a template (bundled or examples). */
function resolveTemplateDir(templateId: string): string | null {
const bundled = join(bundledTemplatesDir, templateId);
if (existsSync(join(bundled, "index.html"))) return bundled;
const example = join(examplesDir, templateId);
if (existsSync(join(example, "index.html"))) return example;
for (const base of [bundledTemplatesDir, examplesDir]) {
const dir = join(base, templateId);
if (existsSync(join(dir, "index.html"))) return dir;
}
return null;
}
@@ -106,7 +97,6 @@ function discoverTemplates(only: string | null): string[] {
const seen = new Set<string>();
const all: string[] = [];
// Scan bundled templates
for (const dir of [bundledTemplatesDir, examplesDir]) {
if (!existsSync(dir)) continue;
for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -124,89 +114,91 @@ function discoverTemplates(only: string | null): string[] {
}
if (only) {
const match = all.find((t) => t === only);
if (!match) {
if (!all.includes(only)) {
console.error(`Template "${only}" not found. Available: ${all.join(", ")}`);
process.exit(1);
}
return [match];
return [only];
}
return all;
}
async function generatePreview(templateId: string): Promise<void> {
const config = TEMPLATE_CONFIG[templateId] ?? {
width: 1920,
height: 1080,
captureTime: 2.0,
};
// Copy template to temp dir
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;
}
const templateSrc = resolveTemplateDir(templateId);
if (!templateSrc) throw new Error(`Template directory not found for "${templateId}"`);
cpSync(templateSrc, tmpDir, { recursive: true });
async function generateThumbnail(templateId: string, projectDir: string): Promise<void> {
const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG;
// Patch out video/audio placeholders, set 10s duration
patchTemplateHtml(tmpDir, 10);
const fileServer = await createFileServer({ projectDir: tmpDir, port: 0 });
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({ projectDir, port: 0 });
try {
const framesDir = join(tmpDir, "_frames");
mkdirSync(framesDir, { recursive: true });
const session = await createCaptureSession(fileServer.url, framesDir, {
width: config.width,
height: config.height,
fps: 30,
format: "png",
});
await initializeSession(session);
// Query actual duration in case template declares something different
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = 10;
duration = 5;
}
// Capture at the configured time, clamped to composition duration
const captureTime = Math.min(config.captureTime, duration * 0.8);
const result = await captureFrame(session, 0, captureTime);
// Copy to output
const outPath = join(outputDir, `${templateId}.png`);
cpSync(result.path, outPath);
console.log(
`${templateId}${outPath} (${config.width}x${config.height} @ ${captureTime.toFixed(1)}s, ${result.captureTimeMs}ms)`,
);
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(tmpDir, { recursive: true, force: true });
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 } = parseArgs();
const { only, skipVideo } = parseArgs();
const templates = discoverTemplates(only);
console.log(`Generating previews for ${templates.length} templates...\n`);
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 generatePreview(templateId);
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 });
}
}