---
title: "Multi Surface Render"
description: "Multi-surface rendering with json-render — one JSON spec produces React web, Next.js, React Native, Ink terminal UIs, PDFs, emails, Remotion videos, OG images, and 3D scenes. Covers renderer target selection, registry mapping, and platform APIs (renderToBuffer, renderToStream, renderToFile). Use when generating output for several platforms or creating PDF reports, email templates, demo videos, or social images from one component spec."
canonical: "https://orchestkit.yonyon.ai/docs/reference/skills/multi-surface-render"
---

# Multi Surface Render

Multi-surface rendering with json-render — one JSON spec produces React web, Next.js, React Native, Ink terminal UIs, PDFs, emails, Remotion videos, OG images, and 3D scenes. Covers renderer target selection, registry mapping, and platform APIs (renderToBuffer, renderToStream, renderToFile). Use when generating output for several platforms or creating PDF reports, email templates, demo videos, or social images from one component spec.

<span className="badge badge-gray">Reference</span> <span className="badge badge-yellow">medium</span>

> **Auto-activated** — this skill loads automatically when Claude detects matching context.

<ContextualSkillSidebar slug="multi-surface-render" />

> **Multi Surface Render** Multi-surface rendering with json-render — one JSON spec produces React web, Next.js, React Native, Ink terminal UIs, PDFs, emails, Remotion videos, OG images, and 3D scenes. Covers renderer target selection, registry mapping, and platform APIs (renderToBuffer, renderToStream, renderToFile). Use when generating output for several platforms or creating PDF reports, email templates, demo videos, or social images from one component spec.


# Multi-Surface Rendering with json-render

Define once, render everywhere. A single json-render catalog and spec can produce React web UIs, PDF reports, HTML emails, Remotion demo videos, and OG images — each surface gets its own registry that maps catalog types to platform-native components.

## Quick Reference

| Category | Rules | Impact | When to Use |
|----------|-------|--------|-------------|
| [Target Selection](#target-selection) | 1 | HIGH | Choosing which renderer for your use case |
| [React Renderer](#react-renderer) | 1 | MEDIUM | Web apps, SPAs, dashboards |
| [PDF & Email Renderer](#pdf--email-renderer) | 1 | HIGH | Reports, documents, notifications |
| [Video & Image Renderer](#video--image-renderer) | 1 | MEDIUM | Demo videos, OG images, social cards |
| [Registry Mapping](#registry-mapping) | 1 | HIGH | Platform-specific component implementations |

**Total: 5 rules across 5 categories**

## How Multi-Surface Rendering Works

1. **One catalog** — Zod-typed component definitions shared across all surfaces
2. **One spec** — flat-tree JSON/YAML describing the UI structure
3. **Many registries** — each surface maps catalog types to its own component implementations
4. **Many renderers** — each package renders the spec using its registry

The catalog is the contract. The spec is the data. The registry is the platform-specific implementation.

## Quick Start — Same Catalog, Different Renderers

### Shared Catalog (used by all surfaces)

```typescript
import { defineCatalog } from '@json-render/core'
import { schema } from '@json-render/react/schema'
import { z } from 'zod'

export const catalog = defineCatalog(schema, {
  components: {
    Heading: {
      props: z.object({
        text: z.string(),
        level: z.enum(['h1', 'h2', 'h3']),
      }),
      children: false,
    },
    Paragraph: {
      props: z.object({ text: z.string() }),
      children: false,
    },
    StatCard: {
      props: z.object({
        label: z.string(),
        value: z.string(),
        trend: z.enum(['up', 'down', 'flat']).optional(),
      }),
      children: false,
    },
  },
})
```

### Render to Web (React)

```tsx
import { Renderer } from '@json-render/react'
import { webRegistry } from './registries/web'

// webRegistry comes from `defineRegistry(catalog, { components })`.
// RendererProps is { spec, registry, loading?, fallback? } — no catalog prop.
export const Dashboard = ({ spec }) => (
  <Renderer spec={spec} registry={webRegistry} />
)
```

### Render to PDF

```typescript
import { renderToBuffer, renderToFile } from '@json-render/react-pdf'
import { pdfRegistry } from './registries/pdf'

// Buffer for HTTP response. PDF options are { registry?, state?, handlers? }.
// includeStandard is an EMAIL option, not a PDF one (see references/upstream-pdf.md).
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })

// Direct file output — renderToFile(spec, filePath, options?)
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })
```

### Render to Email

```typescript
import { renderToHtml } from '@json-render/react-email'
import { emailRegistry } from './registries/email'

const html = await renderToHtml(spec, { registry: emailRegistry })
await sendEmail({ to: user.email, subject: 'Weekly Report', html })
```

### Render to OG Image (Satori)

```typescript
import { renderToSvg, renderToPng } from '@json-render/image'
import { imageRegistry } from './registries/image'

const png = await renderToPng(spec, {
  registry: imageRegistry,
  width: 1200,
  height: 630,
})
```

### Render to Video (Remotion)

```tsx
// Verified 2026-07-31 against @json-render/remotion@0.19.0: the export is
// `Renderer` and its props are { spec, components }. fps and durationInFrames
// belong on Remotion's own Composition, not on this renderer.
import { Renderer } from '@json-render/remotion'
import { remotionComponents } from './registries/remotion'

export const DemoVideo = () => (
  <Renderer spec={spec} components={remotionComponents} />
)
```

### Render to Terminal (Ink, 0.15+)

```tsx
import { render } from 'ink'
import { Renderer } from '@json-render/ink'
import { catalog } from './catalog'
import { inkRegistry } from './registries/ink'

render(<Renderer spec={spec} catalog={catalog} registry={inkRegistry} />)
```

Useful for `/ork:*` CLI dashboards and streaming agent chat interfaces — ships 20+ Ink-native components (Box, Text, Spinner, Table, Markdown, Progress, etc.).

### Render to Next.js App (0.16+)

```typescript
// createNextApp lives on the /server subpath, not the package root.
import { createNextApp } from '@json-render/next/server'

const { getPageData, generateMetadata, generateStaticParams } = createNextApp({
  spec,                        // NextAppSpec: routes keyed by Next.js URL patterns
  loaders: { getPost },        // server-side data loaders referenced by route.loader
})
```

It does **not** scaffold a project on disk. `createNextApp` returns the server-side pieces you
re-export from a catch-all route, and the page itself renders through `PageRenderer`:

```tsx
// app/[[...slug]]/page.tsx
export { generateMetadata, generateStaticParams }

export default async function Page({ params }) {
  const data = await getPageData(params)
  if (!data) notFound()
  return <PageRenderer {...data} registry={webRegistry} />
}
```

A spec describes a route tree (pages, layouts, metadata, loading and error states), not just a
component tree.

## Decision Matrix — When to Use Each Target

| Target | Package | When to Use | Output |
|--------|---------|-------------|--------|
| React | `@json-render/react` | Web apps, SPAs | JSX |
| Next.js | `@json-render/next` *(0.16+)* | Full apps: routes, layouts, SSR, metadata | Next.js app |
| Vue | `@json-render/vue` | Vue projects | Vue components |
| Svelte | `@json-render/svelte` | Svelte projects | Svelte components |
| Svelte+shadcn | `@json-render/shadcn-svelte` *(0.16+)* | 36-component Svelte 5 catalog | Svelte + Tailwind |
| React Native | `@json-render/react-native` | Mobile apps (25+ components) | Native views |
| Terminal | `@json-render/ink` *(0.15+)* | CLI UIs, TUIs, streaming chat | Ink (terminal) |
| PDF | `@json-render/react-pdf` | Reports, documents | PDF buffer/file |
| Email | `@json-render/react-email` | Notifications, digests | HTML string |
| Remotion | `@json-render/remotion` | Demo videos, marketing | MP4/WebM |
| Image | `@json-render/image` | OG images, social cards | SVG/PNG (Satori) |
| YAML | `@json-render/yaml` *(0.14+)* | Token optimization, streaming parser | YAML string |
| MCP | `@json-render/mcp` | Claude/Cursor/ChatGPT conversations | Sandboxed iframe |
| 3D | `@json-render/react-three-fiber` | 3D scenes (19 components, verified 2026-07-31; roster lives upstream) | Three.js canvas |
| Codegen | `@json-render/codegen` | Source code from specs | TypeScript/JSX |

All `@json-render/*` renderers are verified against **0.20.0** (`@json-render/core`).
The 0.19.0 to 0.20.0 export surface went 84 to 87 symbols with zero removals, so every
API documented here still resolves. 0.20.0 adds named slots (`slots?: Record<string,
string[]>` on `UIElement`, with catalogs declaring `slots: ["default", "header", ...]`),
nested repeats via an item-relative `repeat.statePath` of the form `\{"$item": "employees"\}`,
and item-scoped visibility so a `repeat` plus `visible: \{"$item": ...\}` on the same element
filters items instead of failing. One breaking change, which no rule in this skill uses:
`ActionExecutionContext.executeAction` now takes an `ActionBinding` rather than a bare
action name, which only affects hand-written custom renderer bridges.

Load `rules/target-selection.md` for detailed selection criteria and trade-offs.

## Upstream coverage (do not restate)

This skill wraps `@json-render/*`. Vendor documentation is fetched, not repeated. What survives here
is the house delta: `references/ork-delta.md` plus the five rules.

| Topic | Source |
|-------|--------|
| Full renderer signatures and option objects (`renderToBuffer` / `renderToFile` / `renderToStream`, `renderToHtml` / `renderToPlainText`, `renderToSvg` / `renderToPng`, Remotion exports) | `references/upstream-pdf.md`, `upstream-email.md`, `upstream-image.md`, `upstream-remotion.md` (vendored verbatim; re-sync with `bash scripts/sync-vercel-skills.sh`) |
| Standard component rosters per target (`Document`, `Page`, `Table`, email `Section` / `Row` / `Column`, Remotion transitions and effects) | the same four vendored `references/upstream-*.md` files |
| `&lt;Renderer&gt;` props, `defineRegistry`, `useUIStream` | https://github.com/vercel-labs/json-render/tree/main/packages/react. The 0.19 prop-shape correction (no `catalog` prop, no top-level `onError`) is a house finding and stays in `rules/react-renderer.md` |
| Email client constraints: 600px container, table layout, inline styles, absolute image URLs | `references/upstream-email.md` ("Email Best Practices") |
| Satori CSS support matrix | https://github.com/vercel/satori. The working subset this skill designs image registries against stays in `rules/video-image-renderer.md` |
| react-pdf style property support (flexbox set, no grid) | https://react-pdf.org/styling |
| Remotion render cost and cloud rendering | https://www.remotion.dev/docs/lambda |
| Per-package capability and output matrix | the house target picks stay in the Decision Matrix above and in `rules/target-selection.md`; per-package detail at https://github.com/vercel-labs/json-render |

Read `references/ork-delta.md` before writing renderer code: it carries the API-drift rule, the
Remotion and PDF latency budgets, and the PDF / React Native registry layout ceiling.

## PDF Renderer — Reports and Documents

The `@json-render/react-pdf` package renders specs to PDF using react-pdf under the hood. Three output modes: buffer, file, and stream.

```typescript
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'

// In-memory buffer (for HTTP responses, S3 upload)
// PDF options are { registry?, state?, handlers? }, no catalog field
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
res.send(buffer)

// Direct file write — renderToFile(spec, filePath, options?)
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })

// Streaming (for large documents)
const stream = await renderToStream(spec, { registry: pdfRegistry })
stream.pipe(res)
```

Load `rules/pdf-email-renderer.md` for PDF registry patterns and email rendering.

## Image Renderer — OG Images and Social Cards

The `@json-render/image` package uses Satori to convert specs to SVG, then optionally to PNG. Designed for server-side generation of social media images.

```typescript
import { renderToSvg, renderToPng } from '@json-render/image'

// SVG output (smaller, scalable)
const svg = await renderToSvg(spec, {
  registry: imageRegistry,
  width: 1200,
  height: 630,
})

// PNG output (universal compatibility)
const png = await renderToPng(spec, {
  registry: imageRegistry,
  width: 1200,
  height: 630,
})
```

Load `rules/video-image-renderer.md` for Satori constraints and Remotion composition patterns.

## Registry Mapping — Same Catalog, Platform-Specific Components

Each surface needs its own registry. The registry maps catalog types to platform-specific component implementations while the catalog and spec stay identical.

```typescript
// Web registry — uses HTML elements
const webRegistry = {
  Heading: ({ text, level }) => {
    const Tag = level // h1, h2, h3
    return <Tag className="font-bold">{text}</Tag>
  },
  StatCard: ({ label, value, trend }) => (
    <div className="rounded border p-4">
      <span className="text-sm text-gray-500">{label}</span>
      <strong className="text-2xl">{value}</strong>
    </div>
  ),
}

// PDF registry — uses react-pdf primitives
import { Text, View } from '@react-pdf/renderer'
const pdfRegistry = {
  Heading: ({ text, level }) => (
    <Text style={{ fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
      {text}
    </Text>
  ),
  StatCard: ({ label, value }) => (
    <View style={{ border: '1pt solid #ccc', padding: 8 }}>
      <Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
      <Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
    </View>
  ),
}
```

Load `rules/registry-mapping.md` for registry creation patterns and type safety.

## Rule Details

### Target Selection

Decision criteria for choosing the right renderer target.

| Rule | File | Key Pattern |
|------|------|-------------|
| Target Selection | `rules/target-selection.md` | Use case mapping, output format constraints |

### React Renderer

Web rendering with the `&lt;Renderer&gt;` component.

| Rule | File | Key Pattern |
|------|------|-------------|
| React Renderer | `rules/react-renderer.md` | `&lt;Renderer&gt;` component, streaming, error boundaries |

### PDF & Email Renderer

Server-side rendering to PDF buffers/files and HTML email strings.

| Rule | File | Key Pattern |
|------|------|-------------|
| PDF & Email | `rules/pdf-email-renderer.md` | renderToBuffer, renderToFile, renderToHtml |

### Video & Image Renderer

Remotion compositions and Satori image generation.

| Rule | File | Key Pattern |
|------|------|-------------|
| Video & Image | `rules/video-image-renderer.md` | Renderer (Remotion), renderToPng, renderToSvg |

### Registry Mapping

Creating platform-specific registries for a shared catalog.

| Rule | File | Key Pattern |
|------|------|-------------|
| Registry Mapping | `rules/registry-mapping.md` | Per-platform registries, type-safe mapping |

## Key Decisions

| Decision | Recommendation |
|----------|----------------|
| PDF library | Use `@json-render/react-pdf` (react-pdf), not Puppeteer screenshots |
| Email rendering | Use `@json-render/react-email` (react-email), not MJML or custom HTML |
| OG images | Use `@json-render/image` (Satori), not Puppeteer or canvas |
| Video | Use `@json-render/remotion` (Remotion), not FFmpeg scripts |
| Registry per platform | Always separate registries; never one registry for all surfaces |
| Catalog sharing | One catalog definition shared via import across all registries |

## Common Mistakes

1. Building separate component trees for each surface — defeats the purpose; share the catalog and spec
2. Using Puppeteer to screenshot React for PDF generation — slow, fragile; use native react-pdf rendering
3. One giant registry covering all platforms — impossible since PDF uses `&lt;View&gt;`/`&lt;Text&gt;`, web uses `<div>`/`<span>`
4. Forgetting Satori limitations — no CSS grid, limited flexbox; design image registries with these constraints
5. Duplicating catalog definitions per surface — one catalog, many registries; the catalog is the contract

## Related Skills

- `ork:json-render-catalog` — Catalog definition patterns with Zod, shadcn components
- `ork:demo-producer` — Video production pipeline using Remotion
- `ork:mcp-visual-output` — Rendering specs in Claude/Cursor via MCP


---

## Rules (5)

### Use native react-pdf and react-email renderers instead of browser-based workarounds — HIGH


## PDF & Email Renderer

`@json-render/react-pdf` renders specs to PDF using react-pdf primitives (`View`, `Text`, `Image`). `@json-render/react-email` renders specs to HTML email strings using react-email components. Both validate against the same catalog.

**Incorrect — using Puppeteer to screenshot React for PDF:**
```typescript
// WRONG: Launches a browser, takes a screenshot, converts to PDF
import puppeteer from 'puppeteer'

async function generatePdf(spec) {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  await page.setContent(renderToString(<Dashboard spec={spec} />))
  const pdf = await page.pdf({ format: 'A4' })
  await browser.close()
  return pdf // slow, ~2s startup, CSS rendering differences, no catalog validation
}
```

**Correct — native PDF rendering via react-pdf:**
```typescript
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'
import { catalog } from './catalog'
import { pdfRegistry } from './registries/pdf'

// Buffer — for HTTP responses, S3 upload, attachments
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })

// File — direct disk write
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })

// Stream — for large documents, pipe to HTTP response
const stream = await renderToStream(spec, { registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
stream.pipe(res)
```

**Incorrect — manual HTML string for email:**
```typescript
// WRONG: Manual HTML, no validation, rendering inconsistencies
const html = `<table><tr><td>${data.title}</td></tr></table>`
```

**Correct — react-email rendering:**
```typescript
import { renderToHtml } from '@json-render/react-email'
import { catalog } from './catalog'
import { emailRegistry } from './registries/email'

const html = await renderToHtml(spec, { registry: emailRegistry })
await transporter.sendMail({ to: user.email, subject: 'Report', html })
```

**Key rules:**
- Use `renderToBuffer` for in-memory PDF (HTTP responses, email attachments, cloud storage upload)
- Use `renderToFile` for disk output (batch report generation, CI artifacts)
- Use `renderToStream` for large documents to avoid buffering the entire PDF in memory
- PDF registry components must use react-pdf primitives (`View`, `Text`, `Image`) — not HTML elements
- Email registry components must use react-email primitives (`Section`, `Text`, `Heading`) — not arbitrary HTML
- Both renderers validate specs against the catalog — invalid types or props throw at render time

### PDF Registry Pattern

```typescript
import { View, Text, StyleSheet } from '@react-pdf/renderer'

const styles = StyleSheet.create({
  heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  card: { border: '1pt solid #e5e7eb', padding: 12, borderRadius: 4 },
})

export const pdfRegistry = {
  Heading: ({ text, level }) => (
    <Text style={{ ...styles.heading, fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
      {text}
    </Text>
  ),
  StatCard: ({ label, value }) => (
    <View style={styles.card}>
      <Text style={{ fontSize: 10, color: '#6b7280' }}>{label}</Text>
      <Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
    </View>
  ),
}
```


### Use the Renderer component with catalog validation for web rendering — MEDIUM


## React Renderer

The `&lt;Renderer&gt;` component from `@json-render/react` validates specs against the catalog at runtime and renders each element using the registry. Never parse specs manually.

**Incorrect — parsing the spec manually:**
```tsx
// WRONG: Manual parsing, no validation, no streaming
function Dashboard({ spec }) {
  return (
    <div>
      {Object.entries(spec.elements).map(([id, el]) => {
        const Component = components[el.type] // no catalog validation
        return <Component key={id} {...el.props} />
      })}
    </div>
  )
}
```

**Correct — using the Renderer component:**
```tsx
import { Renderer, defineRegistry } from '@json-render/react'
import { catalog } from './catalog'
import { webComponents } from './registries/web'

const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })

function Dashboard({ spec }) {
  return (
    <Renderer
      spec={spec}
      registry={webRegistry}
      fallback={<LoadingSkeleton />}
    />
  )
}
```

**Key rules:**
- Build the registry with `defineRegistry(catalog, \{ components \})` — this binds catalog validation to the registry; `&lt;Renderer&gt;` receives only `spec` and `registry` (no `catalog` prop in 0.19)
- `RendererProps` is `\{ spec, registry, loading?, fallback? \}` — no `catalog`, `components`, `directives`, or `onError` at top level
- Use `fallback` prop for loading states during progressive streaming
- Wrap in a React error boundary for graceful degradation
- For streaming specs (AI generating in real-time), the Renderer updates progressively as elements arrive
- The registry maps catalog types to React components — keep it separate from the catalog definition

### Progressive Streaming Pattern

```tsx
import { Renderer, defineRegistry, useUIStream } from '@json-render/react'
import { catalog } from './catalog'
import { webComponents } from './registries/web'

const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })

function StreamingDashboard({ specStream }) {
  const spec = useUIStream(specStream) // updates as patches arrive

  return (
    <Renderer
      spec={spec}
      registry={webRegistry}
      fallback={<Skeleton />}
    />
  )
}
```

Elements render as soon as their props are complete — the user sees the UI building in real-time.


### Create separate registries per platform sharing a single catalog — HIGH


## Registry Mapping

A registry maps each catalog type to a platform-specific component implementation. The catalog (Zod schemas) and spec (flat-tree data) stay identical across surfaces. Only the registry changes.

**Incorrect — one giant registry trying to cover all platforms:**
```typescript
// WRONG: Impossible — PDF needs View/Text, web needs div/span
const universalRegistry = {
  Heading: ({ text, level, platform }) => {
    if (platform === 'pdf') return <Text style={...}>{text}</Text>
    if (platform === 'email') return <Heading as={level}>{text}</Heading>
    return <h1>{text}</h1> // web fallback
  },
}
```

**Correct — separate registries per platform, same catalog:**
```typescript
import { catalog } from './catalog' // SHARED — one definition

// Web registry
export const webRegistry = {
  Heading: ({ text, level }) => {
    const Tag = level
    return <Tag className="font-bold tracking-tight">{text}</Tag>
  },
  StatCard: ({ label, value, trend }) => (
    <div className="rounded-lg border p-4 shadow-sm">
      <p className="text-sm text-muted-foreground">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
      {trend && <TrendIcon direction={trend} />}
    </div>
  ),
}

// PDF registry
import { View, Text } from '@react-pdf/renderer'
export const pdfRegistry = {
  Heading: ({ text, level }) => (
    <Text style={{ fontSize: level === 'h1' ? 24 : 18, fontWeight: 'bold' }}>
      {text}
    </Text>
  ),
  StatCard: ({ label, value }) => (
    <View style={{ border: '1pt solid #ccc', padding: 8 }}>
      <Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
      <Text style={{ fontSize: 18 }}>{value}</Text>
    </View>
  ),
}

// Email registry
import { Section, Text as EmailText, Heading as EmailHeading } from '@react-email/components'
export const emailRegistry = {
  Heading: ({ text, level }) => (
    <EmailHeading as={level}>{text}</EmailHeading>
  ),
  StatCard: ({ label, value }) => (
    <Section style={{ border: '1px solid #e5e7eb', padding: '12px' }}>
      <EmailText style={{ fontSize: '12px', color: '#6b7280' }}>{label}</EmailText>
      <EmailText style={{ fontSize: '20px', fontWeight: 'bold' }}>{value}</EmailText>
    </Section>
  ),
}
```

**Key rules:**
- One catalog, many registries — the catalog defines WHAT can be rendered, registries define HOW
- Every catalog type must have an entry in each registry — missing entries throw at render time
- Registry components receive the same props defined in the catalog Zod schema
- Never add platform-specific props to the catalog — the catalog is platform-agnostic
- Organize registries in `./registries/web.ts`, `./registries/pdf.ts`, `./registries/email.ts`
- Use `InferCatalogComponents&lt;typeof catalog&gt;` type to ensure registries match the catalog

### Type-Safe Registry Pattern

```typescript
import type { InferCatalogComponents } from '@json-render/core'
import type { catalog } from './catalog'

// TypeScript ensures every catalog type is implemented
export const webRegistry: InferCatalogComponents<typeof catalog> = {
  Heading: ({ text, level }) => { /* ... */ },
  StatCard: ({ label, value, trend }) => { /* ... */ },
  // Missing type → TypeScript error
}
```

### File Organization

```
src/
  catalog.ts              # Shared catalog (Zod schemas)
  registries/
    web.ts                # React/HTML components
    pdf.ts                # react-pdf View/Text components
    email.ts              # react-email Section/Text components
    image.ts              # Satori-compatible inline-style components
    remotion.ts           # Remotion-animated components
```


### Select renderer target based on output format and platform constraints — HIGH


## Target Selection

Choose the renderer target based on what the output is, not what framework you use. Each target maps to a specific `@json-render/*` package with its own rendering pipeline.

**Incorrect — building separate templates for each surface:**
```typescript
// WRONG: Separate template systems, no shared catalog
const webDashboard = buildReactComponents(data)
const pdfReport = buildPdfWithPuppeteer(data)     // puppeteer screenshot
const emailDigest = buildMjmlEmail(data)           // separate MJML templates
const ogImage = buildCanvasImage(data)             // manual canvas drawing
```

**Correct — one catalog, one spec, multiple registries:**
```typescript
import { catalog } from './catalog'
import { defineRegistry } from '@json-render/react'

// Same spec, different renderers
import { Renderer } from '@json-render/react'           // web
import { renderToBuffer } from '@json-render/react-pdf' // pdf
import { renderToHtml } from '@json-render/react-email' // email
import { renderToPng } from '@json-render/image'        // og image

const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })

// Each renderer uses the same catalog + spec, different registry
const webUi = <Renderer spec={spec} registry={webRegistry} />
const pdf = await renderToBuffer(spec, { registry: pdfRegistry })
const html = await renderToHtml(spec, { registry: emailRegistry })
const png = await renderToPng(spec, { registry: imageRegistry, width: 1200, height: 630 })
```

**Key rules:**
- Match target to output format: PDF document = `react-pdf`, HTML email = `react-email`, image = `image`
- Never use Puppeteer/Playwright to screenshot a React page for PDF — use native `@json-render/react-pdf`
- Never build custom MJML/HTML templates when `@json-render/react-email` exists
- If output is a file (PDF, PNG, MP4), use the server-side renderer — not the React `&lt;Renderer&gt;` component
- Multiple targets in one project is the normal case — that is the entire point of json-render

### Selection Checklist

| Need | Target | Package |
|------|--------|---------|
| Interactive web UI | React | `@json-render/react` |
| Downloadable document | PDF | `@json-render/react-pdf` |
| Transactional email | Email | `@json-render/react-email` |
| Social preview card | Image | `@json-render/image` |
| Marketing video | Video | `@json-render/remotion` |
| Mobile app screen | React Native | `@json-render/react-native` |
| AI conversation output | MCP | `@json-render/mcp` |
| Source code generation | Codegen | `@json-render/codegen` |


### Use Remotion compositions and Satori for video and image generation from specs — MEDIUM


## Video & Image Renderer

`@json-render/remotion` wraps specs into Remotion compositions for MP4/WebM video. `@json-render/image` uses Satori to render specs as SVG, then optionally converts to PNG for OG images and social cards.

**Incorrect — manually creating Remotion timelines:**
```tsx
// WRONG: Manual timeline, no catalog validation, duplicated rendering logic
export const MyVideo = () => (
  <Composition
    id="demo"
    component={() => (
      <div>
        <h1>{data.title}</h1>
        <p>{data.description}</p>
      </div>
    )}
    durationInFrames={150}
    fps={30}
    width={1920}
    height={1080}
  />
)
```

**Correct, Renderer from spec:**
```tsx
// Verified 2026-07-31 against @json-render/remotion@0.19.0 dist/index.d.ts.
// The export is `Renderer`, and its props are { spec, components } only.
import { Renderer } from '@json-render/remotion'
import { remotionComponents } from './registries/remotion'

const DemoVideo = () => <Renderer spec={spec} components={remotionComponents} />

// fps, durationInFrames and dimensions belong to Remotion's own Composition,
// not to this renderer. Wire them where Remotion expects them:
export const Root = () => (
  <Composition
    id="demo"
    component={DemoVideo}
    fps={30}
    durationInFrames={150}
    width={1920}
    height={1080}
  />
)
```

**Incorrect — using Puppeteer for OG images:**
```typescript
// WRONG: Launches browser, screenshots a page, saves as PNG
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setViewport({ width: 1200, height: 630 })
await page.setContent(`<div style="...">${title}</div>`)
const png = await page.screenshot({ type: 'png' })
```

**Correct — Satori-based image rendering:**
```typescript
import { renderToSvg, renderToPng } from '@json-render/image'
import { catalog } from './catalog'
import { imageRegistry } from './registries/image'

// SVG (smaller file size, scalable)
const svg = await renderToSvg(spec, {
  registry: imageRegistry,
  width: 1200,
  height: 630,
})

// PNG (universal compatibility)
const png = await renderToPng(spec, {
  registry: imageRegistry,
  width: 1200,
  height: 630,
})
```

**Key rules:**
- Use `Renderer` from `@json-render/remotion` for videos, passing `\{ spec, components \}`. There is no `JsonRenderComposition` export, and no `catalog` or `registry` prop: timing and dimensions live on Remotion's `Composition`
- Use `renderToPng` for OG images (1200x630 standard) — Satori is server-side, no browser needed
- Satori has CSS limitations: no CSS grid, limited flexbox, no `position: absolute` nesting — design image registries accordingly
- Image registries must use inline styles only — Satori does not support className or CSS files
- For Remotion, the registry can use Remotion animation primitives (`useCurrentFrame`, `interpolate`, `spring`)

### Satori CSS Constraints

| Supported | Not Supported |
|-----------|---------------|
| Flexbox (basic) | CSS Grid |
| `border`, `borderRadius` | `box-shadow` |
| `padding`, `margin` | `position: absolute` (limited) |
| `fontSize`, `fontWeight` | External CSS, className |
| `color`, `backgroundColor` | CSS animations |
| `width`, `height` | Media queries |

### Common OG Image Dimensions

| Platform | Width | Height | Ratio |
|----------|-------|--------|-------|
| Open Graph (general) | 1200 | 630 | 1.91:1 |
| Twitter card | 1200 | 628 | 1.91:1 |
| LinkedIn share | 1200 | 627 | 1.91:1 |
| Facebook share | 1200 | 630 | 1.91:1 |



---

## References (5)

### Ork Delta

# ork delta: multi-surface-render

What this skill knows that the vendor docs do not say. Everything else was retired in favour of a
pointer; see "Upstream coverage (do not restate)" in `SKILL.md`.

## Read json-render render options off the vendored upstream file, never a hand-copied API table

Why: distilled from the retired `references/renderer-api.md`; no traced incident. That file claimed
`catalog` was a required option on every renderer and that `renderToBuffer` accepted `pageSize`,
`orientation` and `margins`. `references/upstream-pdf.md` ("Render APIs") documents the 0.19 second
argument as `\{ registry?, state?, handlers? \}`, and page size, orientation and margins are props of
the `Page` component, not render options. `SKILL.md` and `rules/react-renderer.md` already carried
the corrected shape, so the skill shipped two contradicting answers to the same question.

Upstream: `references/upstream-pdf.md` and `references/upstream-email.md` in this directory, vendored
verbatim by `scripts/sync-vercel-skills.sh`; re-sync before trusting any API claim.

## Treat the vendored upstream export tables as authoritative when a snippet disagrees

Why: distilled from the retired `references/renderer-api.md`; no traced incident. This rule earned
itself the same day it was written. The retired file and every hand-written Remotion snippet in this
skill used `&lt;JsonRenderComposition fps durationInFrames width height&gt;`, while
`references/upstream-remotion.md` listed `Renderer` under Key Exports. The vendored file was right:
`check-import-symbols.mjs` later confirmed against `@json-render/remotion@0.19.0` that
`JsonRenderComposition` has never been exported at all. The snippets were corrected to
`Renderer(\{ spec, components \})` on 2026-07-31, with fps and durationInFrames moved onto Remotion's
own `Composition` where they belong. When a hand-written snippet disagrees with the synced file, the
synced file breaks the tie and the snippet is the thing that gets corrected.

Upstream: `references/upstream-remotion.md` ("Key Exports") in this directory.

## Never render a Remotion video inside a request handler

Why: distilled from the retired `references/target-comparison.md`; no traced incident. Its budget put
a full Remotion render at 10 to 60 seconds with high CPU and memory, against 50 to 500ms for every
other target in the same table. Video is the one surface that cannot sit behind a synchronous HTTP
response: queue it, or push it to cloud rendering.

Upstream: https://www.remotion.dev/docs/lambda

## Pick the PDF output mode by document size, not by convenience

Why: distilled from the retired `references/target-comparison.md`; no traced incident. Its budget put
`renderToBuffer` at 200 to 500ms with the whole document held in memory, against 100 to 300ms to
first byte for `renderToStream`. Those numbers are the reason `rules/pdf-email-renderer.md` routes
large documents through the stream path instead of buffering, and they are what makes the choice
decidable instead of a coin flip.

Upstream: `references/upstream-pdf.md` ("Render APIs") in this directory.

## Build PDF and React Native registries flexbox-only, with no className

Why: distilled from the retired `references/target-comparison.md`; no traced incident. Both surfaces
run a layout engine that is a strict subset of CSS: no grid, no `className`, no `div` or `span`. PDF
needs `View` and `Text` with `StyleSheet.create()`, React Native the same.
`rules/registry-mapping.md` shows the primitives but not the layout ceiling, and the ceiling is what
makes a copied web registry fail at render time rather than at type-check.

Upstream: https://react-pdf.org/styling and https://reactnative.dev/docs/flexbox

## Per-target latency budgets, the full table

Why: distilled from the retired `references/target-comparison.md`. Only the Remotion and
PDF figures survived elsewhere, and a partial table invites the assumption that the
missing targets are free. The house budgets are React under 50ms, Email 50 to 100ms,
Image SVG 100 to 200ms, Image PNG 200 to 400ms, Codegen 50 to 100ms, PDF buffer 200 to
500ms (100 to 300ms to first byte when streaming), Remotion 10 to 60s. These are the
numbers that decide whether a target belongs in a request path or behind a job queue,
and no vendor page publishes a cross-target comparison.
Upstream: none; this is a house measurement across the json-render packages

## Combine targets deliberately, from the house pairings

Why: distilled from the retired `references/target-comparison.md`; pure house guidance
with no vendor equivalent. The pairings we actually ship are React plus PDF for a
download button, React plus Email for a weekly digest, React plus Image for an OG
preview, React plus Remotion for a landing-page demo, and all of them together for a
full marketing suite. The point of the list is that one catalog serves each pair, so the
second target is close to free once the first exists.
Upstream: none; house composition guidance

## Register custom fonts explicitly before rendering PDF

Why: distilled from the retired `references/target-comparison.md`; PDF is the one target
where a custom font must be embedded rather than referenced, and neither
`references/upstream-pdf.md` nor `rules/pdf-email-renderer.md` documents a
font-registration API. A spec that renders correctly on web silently falls back to a
default face in the PDF, which is a visual regression nobody catches in review because
the web preview is fine.
Upstream: https://react-pdf.org/fonts


### Upstream Email

&lt;!-- SYNCED from vercel-labs/json-render (skills/react-email/SKILL.md) --&gt;
&lt;!-- Hash: c6acbfea4b58c07ad9da2169868157bb97016f3cc5f47ffc4502a95146f63713 --&gt;
&lt;!-- Re-sync: bash scripts/sync-vercel-skills.sh --&gt;


# @json-render/react-email

React Email renderer that converts JSON specs into HTML or plain-text email output.

## Quick Start

```typescript
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";

const catalog = defineCatalog(schema, {
  components: standardComponentDefinitions,
});

const spec = {
  root: "html-1",
  elements: {
    "html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
    "head-1": { type: "Head", props: {}, children: [] },
    "body-1": {
      type: "Body",
      props: { style: { backgroundColor: "#f6f9fc" } },
      children: ["container-1"],
    },
    "container-1": {
      type: "Container",
      props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
      children: ["heading-1", "text-1"],
    },
    "heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
    "text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
  },
};

const html = await renderToHtml(spec);
```

## Spec Structure (Element Tree)

Same flat element tree as `@json-render/react`: `root` key plus `elements` map. Root must be `Html`; children of `Html` should be `Head` and `Body`. Use `Container` (e.g. max-width 600px) inside `Body` for client-safe layout.

## Creating a Catalog and Registry

```typescript
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
import { standardComponentDefinitions } from "@json-render/react-email/catalog";
import { Container, Heading, Text } from "@react-email/components";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    ...standardComponentDefinitions,
    Alert: {
      props: z.object({
        message: z.string(),
        variant: z.enum(["info", "success", "warning"]).nullable(),
      }),
      slots: [],
      description: "A highlighted message block",
    },
  },
  actions: {},
});

const { registry } = defineRegistry(catalog, {
  components: {
    Alert: ({ props }) => (
      <Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
        <Text style={{ margin: 0 }}>{props.message}</Text>
      </Container>
    ),
  },
});

const html = await renderToHtml(spec, { registry });
```

## Server-Side Render APIs

| Function | Purpose |
|----------|---------|
| `renderToHtml(spec, options?)` | Render spec to HTML email string |
| `renderToPlainText(spec, options?)` | Render spec to plain-text email string |

`RenderOptions`: `registry`, `includeStandard` (default true), `state` (for `$state` / `$cond`).

## Visibility and State

Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), nested repeat paths with `\{ "$item": "field" \}`, and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve.

## Server-Safe Import

Import schema and catalog without React or `@react-email/components`:

```typescript
import { schema, standardComponentDefinitions } from "@json-render/react-email/server";
```

## Key Exports

| Export | Purpose |
|--------|---------|
| `defineRegistry` | Create type-safe component registry from catalog |
| `Renderer` | Render spec in browser (e.g. preview); use with `JSONUIProvider` for state/actions |
| `createRenderer` | Standalone renderer component with state/actions/validation |
| `renderToHtml` | Server: spec to HTML string |
| `renderToPlainText` | Server: spec to plain-text string |
| `schema` | Email element schema |
| `standardComponents` | Pre-built component implementations |
| `standardComponentDefinitions` | Catalog definitions (Zod props) |

## Sub-path Exports

| Path | Purpose |
|------|---------|
| `@json-render/react-email` | Full package |
| `@json-render/react-email/server` | Schema and catalog only (no React) |
| `@json-render/react-email/catalog` | Standard component definitions and types |
| `@json-render/react-email/render` | Render functions only |

## Standard Components

All components accept a `style` prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS.

### Document structure

| Component | Description |
|-----------|-------------|
| `Html` | Root wrapper (lang, dir). Children: Head, Body. |
| `Head` | Email head section. |
| `Body` | Body wrapper; use `style` for background. |

### Layout

| Component | Description |
|-----------|-------------|
| `Container` | Constrain width (e.g. max-width 600px). |
| `Section` | Group content; table-based for compatibility. |
| `Row` | Horizontal row. |
| `Column` | Column in a Row; set width via style. |

### Content

| Component | Description |
|-----------|-------------|
| `Heading` | Heading text (as: h1–h6). |
| `Text` | Body text. |
| `Link` | Hyperlink (text, href). |
| `Button` | CTA link styled as button (text, href). |
| `Image` | Image from URL (src, alt, width, height). |
| `Hr` | Horizontal rule. |

### Utility

| Component | Description |
|-----------|-------------|
| `Preview` | Inbox preview text (inside Html). |
| `Markdown` | Markdown content as email-safe HTML. |

## Email Best Practices

- Keep width constrained (e.g. Container max-width 600px).
- Use inline styles or React Email's style props; many clients strip `&lt;style&gt;` blocks.
- Prefer table-based layout (Section, Row, Column) for broad client support.
- Use absolute URLs for images; many clients block relative or cid: references in some contexts.
- Test in multiple clients (Gmail, Outlook, Apple Mail); use a preview tool or Litmus-like service when possible.


### Upstream Image

&lt;!-- SYNCED from vercel-labs/json-render (skills/image/SKILL.md) --&gt;
&lt;!-- Hash: fc6469e1592a86d4d92058b81704023394ab8cbdd9f422d541ad3a752f2a0e42 --&gt;
&lt;!-- Re-sync: bash scripts/sync-vercel-skills.sh --&gt;


# @json-render/image

Image renderer that converts JSON specs into SVG and PNG images using Satori.

## Quick Start

```typescript
import { renderToPng } from "@json-render/image/render";
import type { Spec } from "@json-render/core";

const spec: Spec = {
  root: "frame",
  elements: {
    frame: {
      type: "Frame",
      props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
      children: ["heading"],
    },
    heading: {
      type: "Heading",
      props: { text: "Hello World", level: "h1", color: "#ffffff" },
      children: [],
    },
  },
};

const png = await renderToPng(spec, {
  fonts: [{ name: "Inter", data: fontData, weight: 400, style: "normal" }],
});
```

## Using Standard Components

```typescript
import { defineCatalog } from "@json-render/core";
import { schema, standardComponentDefinitions } from "@json-render/image";

export const imageCatalog = defineCatalog(schema, {
  components: standardComponentDefinitions,
});
```

## Adding Custom Components

```typescript
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    ...standardComponentDefinitions,
    Badge: {
      props: z.object({ label: z.string(), color: z.string().nullable() }),
      slots: [],
      description: "A colored badge label",
    },
  },
});
```

## Standard Components

| Component | Category | Description |
|-----------|----------|-------------|
| `Frame` | Root | Root container. Defines width, height, background. Must be root. |
| `Box` | Layout | Container with padding, margin, border, absolute positioning |
| `Row` | Layout | Horizontal flex layout |
| `Column` | Layout | Vertical flex layout |
| `Heading` | Content | h1-h4 heading text |
| `Text` | Content | Body text with full styling |
| `Image` | Content | Image from URL |
| `Divider` | Decorative | Horizontal line separator |
| `Spacer` | Decorative | Empty vertical space |

## Key Exports

| Export | Purpose |
|--------|---------|
| `renderToSvg` | Render spec to SVG string |
| `renderToPng` | Render spec to PNG buffer (requires `@resvg/resvg-js`) |
| `schema` | Image element schema |
| `standardComponents` | Pre-built component registry |
| `standardComponentDefinitions` | Catalog definitions for AI prompts |

## Sub-path Exports

| Export | Description |
|--------|-------------|
| `@json-render/image` | Full package: schema, components, render functions |
| `@json-render/image/server` | Schema and catalog definitions only (no React/Satori) |
| `@json-render/image/catalog` | Standard component definitions and types |
| `@json-render/image/render` | Render functions only |


### Upstream Pdf

&lt;!-- SYNCED from vercel-labs/json-render (skills/react-pdf/SKILL.md) --&gt;
&lt;!-- Hash: b791e1340395749f63796e736bc6a7e80cf338403ef763b6961a2c7d4ed863ae --&gt;
&lt;!-- Re-sync: bash scripts/sync-vercel-skills.sh --&gt;


# @json-render/react-pdf

React PDF renderer that generates PDF documents from JSON specs using `@react-pdf/renderer`.

## Installation

```bash
npm install @json-render/core @json-render/react-pdf
```

## Quick Start

```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";

const spec: Spec = {
  root: "doc",
  elements: {
    doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
    page: {
      type: "Page",
      props: { size: "A4" },
      children: ["heading", "table"],
    },
    heading: {
      type: "Heading",
      props: { text: "Invoice #1234", level: "h1" },
      children: [],
    },
    table: {
      type: "Table",
      props: {
        columns: [
          { header: "Item", width: "60%" },
          { header: "Price", width: "40%", align: "right" },
        ],
        rows: [
          ["Widget A", "$10.00"],
          ["Widget B", "$25.00"],
        ],
      },
      children: [],
    },
  },
};

const buffer = await renderToBuffer(spec);
```

## Render APIs

```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";

// In-memory buffer
const buffer = await renderToBuffer(spec);

// Readable stream (pipe to HTTP response)
const stream = await renderToStream(spec);
stream.pipe(res);

// Direct to file
await renderToFile(spec, "./output.pdf");
```

All render functions accept an optional second argument: `\{ registry?, state?, handlers? \}`.

## Standard Components

| Component | Description |
|-----------|-------------|
| `Document` | Top-level PDF wrapper (must be root) |
| `Page` | Page with size (A4, LETTER), orientation, margins |
| `View` | Generic container (padding, margin, background, border) |
| `Row`, `Column` | Flex layout with gap, align, justify |
| `Heading` | h1-h4 heading text |
| `Text` | Body text (fontSize, color, weight, alignment) |
| `Image` | Image from URL or base64 |
| `Link` | Hyperlink with text and href |
| `Table` | Data table with typed columns and rows |
| `List` | Ordered or unordered list |
| `Divider` | Horizontal line separator |
| `Spacer` | Empty vertical space |
| `PageNumber` | Current page number and total pages |

## Custom Catalog

```typescript
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToBuffer } from "@json-render/react-pdf";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    ...standardComponentDefinitions,
    Badge: {
      props: z.object({ label: z.string(), color: z.string().nullable() }),
      slots: [],
      description: "A colored badge label",
    },
  },
  actions: {},
});

const { registry } = defineRegistry(catalog, {
  components: {
    Badge: ({ props }) => (
      <View style={{ backgroundColor: props.color ?? "#e5e7eb", padding: 4 }}>
        <Text>{props.label}</Text>
      </View>
    ),
  },
});

const buffer = await renderToBuffer(spec, { registry });
```

## External Store (Controlled Mode)

Pass a `StateStore` for full control over state:

Nested lists can set `repeat.statePath` to `\{ "$item": "field" \}` inside an enclosing repeat.

```typescript
import { createStateStore } from "@json-render/react-pdf";

const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);
```

## Server-Safe Import

Import schema and catalog without pulling in React:

```typescript
import { schema, standardComponentDefinitions } from "@json-render/react-pdf/server";
```


### Upstream Remotion

&lt;!-- SYNCED from vercel-labs/json-render (skills/remotion/SKILL.md) --&gt;
&lt;!-- Hash: 59d63ac06729795f0981fe0cfadf6c1c34454be0329a34f2d36c9be2391fbbe9 --&gt;
&lt;!-- Re-sync: bash scripts/sync-vercel-skills.sh --&gt;


# @json-render/remotion

Remotion renderer that converts JSON timeline specs into video compositions.

## Quick Start

```typescript
import { Player } from "@remotion/player";
import { Renderer, type TimelineSpec } from "@json-render/remotion";

function VideoPlayer({ spec }: { spec: TimelineSpec }) {
  return (
    <Player
      component={Renderer}
      inputProps={{ spec }}
      durationInFrames={spec.composition.durationInFrames}
      fps={spec.composition.fps}
      compositionWidth={spec.composition.width}
      compositionHeight={spec.composition.height}
      controls
    />
  );
}
```

## Using Standard Components

```typescript
import { defineCatalog } from "@json-render/core";
import {
  schema,
  standardComponentDefinitions,
  standardTransitionDefinitions,
  standardEffectDefinitions,
} from "@json-render/remotion";

export const videoCatalog = defineCatalog(schema, {
  components: standardComponentDefinitions,
  transitions: standardTransitionDefinitions,
  effects: standardEffectDefinitions,
});
```

## Adding Custom Components

```typescript
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    ...standardComponentDefinitions,
    MyCustomClip: {
      props: z.object({ text: z.string() }),
      type: "scene",
      defaultDuration: 90,
      description: "My custom video clip",
    },
  },
});

// Pass custom component to Renderer
<Player
  component={Renderer}
  inputProps={{
    spec,
    components: { MyCustomClip: MyCustomComponent },
  }}
/>
```

## Timeline Spec Structure

```json
{
  "composition": { "id": "video", "fps": 30, "width": 1920, "height": 1080, "durationInFrames": 300 },
  "tracks": [{ "id": "main", "name": "Main", "type": "video", "enabled": true }],
  "clips": [
    { "id": "clip-1", "trackId": "main", "component": "TitleCard", "props": { "title": "Hello" }, "from": 0, "durationInFrames": 90 }
  ],
  "audio": { "tracks": [] }
}
```

## Standard Components

| Component | Type | Description |
|-----------|------|-------------|
| `TitleCard` | scene | Full-screen title with subtitle |
| `TypingText` | scene | Terminal-style typing animation |
| `ImageSlide` | image | Full-screen image display |
| `SplitScreen` | scene | Two-panel comparison |
| `QuoteCard` | scene | Quote with attribution |
| `StatCard` | scene | Animated statistic display |
| `TextOverlay` | overlay | Text overlay |
| `LowerThird` | overlay | Name/title overlay |

## Key Exports

| Export | Purpose |
|--------|---------|
| `Renderer` | Render spec to Remotion composition |
| `schema` | Timeline schema |
| `standardComponents` | Pre-built component registry |
| `standardComponentDefinitions` | Catalog definitions |
| `useTransition` | Transition animation hook |
| `ClipWrapper` | Wrap clips with transitions |
