diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 9d6b07f45c..7bcf9e626d 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -23,7 +23,6 @@ jobs: - "packages/core/**" - "packages/producer/**" - "packages/engine/**" - - "scripts/**" - "Dockerfile*" regression-shards: diff --git a/docs/concepts/compositions.mdx b/docs/concepts/compositions.mdx index ea9872163c..08d8fc0452 100644 --- a/docs/concepts/compositions.mdx +++ b/docs/concepts/compositions.mdx @@ -133,7 +133,7 @@ Compositions can expose variables for dynamic content:
``` -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 Add animations to your compositions with GSAP timelines - + Start from built-in templates for common video patterns diff --git a/docs/docs.json b/docs/docs.json index 6deb4074fc..c8a1633b05 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" diff --git a/docs/guides/templates.mdx b/docs/guides/templates.mdx deleted file mode 100644 index 4bd8bf1c3d..0000000000 --- a/docs/guides/templates.mdx +++ /dev/null @@ -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 -``` - -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 - - - - ### 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 - ``` - - - ### 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/ - ``` - - - ### 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/ - ``` - - - ### 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/ - ``` - - - ### 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/ - ``` - - - -## 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 | - - - 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. - - -## 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 -
- - - - - -
-``` - -After creating a custom template, validate it with the [linter](/packages/cli#lint): - -```bash Terminal -npx hyperframes lint -``` - -## Next Steps - - - - Create, preview, and render your first video - - - Add animations to your template - - - Understand the composition data model - - - Render your composition to MP4 - - diff --git a/docs/guides/troubleshooting.mdx b/docs/guides/troubleshooting.mdx index 85504eaa9c..a9757db3ef 100644 --- a/docs/guides/troubleshooting.mdx +++ b/docs/guides/troubleshooting.mdx @@ -9,7 +9,7 @@ If your issue is about a specific coding mistake (animations not working, video 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
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` diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index e8805b4a66..c92732e1b7 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -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 Add fade, slide, scale, and custom animations to your videos - + Start from built-in templates like Warm Grain and Swiss Grid diff --git a/docs/snippets/TemplateCard.jsx b/docs/snippets/TemplateCard.jsx new file mode 100644 index 0000000000..b14b6bc082 --- /dev/null +++ b/docs/snippets/TemplateCard.jsx @@ -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 ( + setHovering(true)} + onMouseLeave={() => setHovering(false)} + > +
+ {`${title} + {hovering && ( +
+
+

+ {title} +

+

+ {description} +

+
+
+ ); +} + +export function TemplateGrid({ children }) { + return ( +
+ {children} +
+ ); +} diff --git a/docs/template-gallery.css b/docs/template-gallery.css new file mode 100644 index 0000000000..82727b1104 --- /dev/null +++ b/docs/template-gallery.css @@ -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; +} diff --git a/docs/template-gallery.js b/docs/template-gallery.js new file mode 100644 index 0000000000..67519cd808 --- /dev/null +++ b/docs/template-gallery.js @@ -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 }); diff --git a/docs/templates.mdx b/docs/templates.mdx new file mode 100644 index 0000000000..2cdba614b1 --- /dev/null +++ b/docs/templates.mdx @@ -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 +``` + +## Landscape Templates + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +## Portrait Templates + +
+
+
+
+ + + 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 + ``` + + +## 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 + + + + ### 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/ + ``` + + + ### play-mode + + Playful elastic animations with bold, energetic motion. + + ``` + my-video/ + ├── meta.json + ├── index.html + ├── compositions/ + │ ├── intro.html + │ ├── stats.html + │ └── captions.html + └── assets/ + ``` + + + ### 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/ + ``` + + + ### vignelli + + Bold typography with red accents (1080×1920 portrait). + + ``` + my-video/ + ├── meta.json + ├── index.html + ├── compositions/ + │ ├── overlays.html + │ └── captions.html + └── assets/ + ``` + + + ### kinetic-type + + Bold kinetic typography promo with dramatic text animations. + + ``` + my-video/ + ├── meta.json + ├── index.html + └── compositions/ + └── main-graphics.html + ``` + + + ### decision-tree + + Animated flowchart with branching paths and progressive reveal. + + ``` + my-video/ + ├── meta.json + ├── index.html + └── compositions/ + └── decision_tree.html + ``` + + + ### 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 + ``` + + + ### nyt-graph + + Animated data chart in print editorial style. + + ``` + my-video/ + ├── meta.json + ├── index.html + └── compositions/ + └── nyt-chart.html + ``` + + + ### blank + + Empty composition with just the scaffolding. + + ``` + my-video/ + ├── meta.json + ├── index.html + └── compositions/ + └── captions.html + ``` + + + +## 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 +
+ + + + + +
+``` + +After creating a custom template, validate it with the [linter](/packages/cli#lint): + +```bash Terminal +npx hyperframes lint +``` + +## Next Steps + + + + Create, preview, and render your first video + + + Add animations to your template + + + Understand the composition data model + + + Render your composition to MP4 + + diff --git a/scripts/generate-template-previews.ts b/scripts/generate-template-previews.ts index a203f837bb..5d88de6d6e 100644 --- a/scripts/generate-template-previews.ts +++ b/scripts/generate-template-previews.ts @@ -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/.png + * Uses @hyperframes/producer to render PNG thumbnails and short MP4 preview + * videos of each built-in template. + * + * Output: docs/images/templates/.png + .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 = { - "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(/]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, ""); content = content.replace(/]*src="__VIDEO_SRC__"[^>]*>/g, ""); content = content.replace(/]*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(); 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 { - 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 }); - - // Patch out video/audio placeholders, set 10s duration - patchTemplateHtml(tmpDir, 10); +async function generateThumbnail(templateId: string, projectDir: string): Promise { + const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG; - 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 { + 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 { - 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 }); } }