Files
hyperframes/docs/packages/producer.mdx
T
Miguel Ángel 395fb9c084 feat: add browser GPU render mode (#571)
## Problem

HyperFrames already had `--gpu`, but that flag only controlled FFmpeg hardware encoding. The browser capture path still forced Chrome/WebGL through SwiftShader software GL via `--use-angle=swiftshader`, so WebGL-heavy local renders could leave the biggest bottleneck on the CPU path.

That made the existing flag naming easy to misread: `--gpu` sounded like it accelerated the whole render, but it did not change the browser frame-capture backend.

## What this fixes

- Enables host browser GPU acceleration automatically for local CLI renders.
- Adds `--no-browser-gpu` as the local opt-out for software Chrome/WebGL capture.
- Keeps `--browser-gpu` as an explicit local browser-GPU request.
- Adds `browserGpuMode: "software" | "hardware"` to engine config, with `PRODUCER_BROWSER_GPU_MODE` env support for lower-level producer users.
- Keeps Docker browser capture on the deterministic software path.
- Maps hardware browser GPU mode to platform-native Chrome backends:
  - macOS: Metal-backed ANGLE
  - Windows: D3D11-backed ANGLE
  - Linux: EGL
- Blocks explicit `--browser-gpu --docker` with a clear error because Docker browser GPU passthrough is not cross-platform.
- Clarifies docs so `--gpu` means FFmpeg encoder GPU and browser GPU means Chrome/WebGL capture GPU.
- Keeps encoder backend selection auto-detected from FFmpeg capabilities:
  - NVIDIA: NVENC
  - macOS: VideoToolbox
  - Linux: VAAPI
  - Intel: QSV

## Why two flags

There are two separate GPU surfaces in the render pipeline:

1. Browser GPU controls Chrome frame capture.
   - Affects WebGL, canvas, CSS rendering, compositing, and screenshot capture inside the browser.
   - This is enabled automatically for local CLI renders.
   - Use `--no-browser-gpu` when you want the software browser baseline.

2. `--gpu` controls FFmpeg video encoding.
   - Affects the final encode step after frames have already been captured.
   - The concrete encoder is auto-detected from the host FFmpeg build and hardware.
   - It can be faster for some machines/codecs, but it is not equivalent to browser rendering acceleration.

The controls stay independent because users may want:

- `hyperframes render` for the fast local default with browser GPU capture.
- `hyperframes render --no-browser-gpu` for the software-browser local baseline.
- `hyperframes render --gpu` for browser GPU capture plus hardware FFmpeg encoding.
- `hyperframes render --no-browser-gpu --gpu` for software browser capture plus hardware FFmpeg encoding.
- `hyperframes render --docker` for deterministic browser capture.

## Why `--gpu` does not imply browser GPU

Keeping `--gpu` scoped to FFmpeg encoding avoids a semantic break and keeps the risk profile explicit:

- `--gpu` already means encoder acceleration. Expanding it to also change Chrome capture would silently alter behavior for users who only wanted hardware encoding.
- Browser GPU and encoder GPU have different portability. Encoder GPU can work in Docker when the host exposes the right devices; browser GPU passthrough is not cross-platform, so this PR intentionally blocks explicit `--browser-gpu --docker`.
- The Apple presentation benchmark shows why the controls should stay separate: browser GPU capture was the useful improvement, while macOS VideoToolbox via `--gpu` was slower and produced larger output for this `standard` H.264 run.

If HyperFrames later wants a single umbrella acceleration control, it should be explicit, for example `--acceleration browser|encoder|all` or `--gpu=browser|encoder|all`, rather than changing the meaning of the existing boolean `--gpu`.

## Root cause

`buildChromeArgs()` always injected `--use-gl=angle --use-angle=swiftshader`. `disableGpu` only appended `--disable-gpu`; it did not provide a hardware-GPU mode. That made the public `--gpu` flag look broader than it was, because render capture stayed software-backed even when encoder GPU was requested.

## Verification

### Local checks

- `bun install`
- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/engine test src/config.test.ts src/services/browserManager.test.ts`
- `bun run --filter @hyperframes/cli test src/utils/dockerRunArgs.test.ts src/commands/render.test.ts`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `cd packages/producer && bunx vitest run src/services/renderOrchestrator.test.ts`
- `bunx oxlint packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts packages/cli/src/utils/dockerRunArgs.ts packages/cli/src/utils/dockerRunArgs.test.ts packages/engine/src/config.ts packages/engine/src/config.test.ts packages/engine/src/services/browserManager.ts packages/engine/src/services/browserManager.test.ts packages/producer/src/services/renderOrchestrator.test.ts`
- `bunx oxfmt --check ...` on changed source/docs files
- `git diff --check`
- `bun packages/cli/src/cli.ts render --help | rg -n "browser-gpu|no-browser-gpu|GPU"`
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --output /tmp/hf-auto-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
  - Render plan prints `GPU: browser GPU (auto)`.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --no-browser-gpu --output /tmp/hf-software-browser-gpu-smoke.mp4 --workers 1 --quality draft --fps 24 --strict`
  - Render plan does not print browser GPU.
- `bun packages/cli/src/cli.ts render packages/producer/tests/css-spinner-render-compat/src --docker --browser-gpu --output /tmp/should-not-render.mp4`
  - Exits 1 with `Browser GPU is local-only`.
- `buildDockerRunArgs()` regression coverage asserts Docker container args include `--no-browser-gpu`, preventing nested container renders from re-enabling browser GPU through the local CLI default.
- `resolveBrowserGpuForCli()` regression coverage asserts `PRODUCER_BROWSER_GPU_MODE=software` opts out when no CLI browser-GPU flag is supplied, while explicit `--browser-gpu` / `--no-browser-gpu` still win.
- `ffmpeg -v error -i /tmp/hf-auto-browser-gpu-smoke.mp4 -f null -`
- `ffmpeg -v error -i /tmp/hf-software-browser-gpu-smoke.mp4 -f null -`
- `ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of json /tmp/hf-browser-gpu-smoke.mp4` -> H.264, 1920x1080, 24fps, 5.0s

### Apple presentation benchmark

Rendered `/Users/miguel07code/Downloads/apple-presentation.zip` as supplied after extracting to `/tmp/hf-apple-profile/apple-presentation`.

Fixed settings:

- 1920x1080
- 30fps
- `standard` quality
- 4240 frames
- 141.32s duration
- 8-worker cap; render auto-calibration used 6 capture workers
- macOS host detected FFmpeg GPU encoder: `videotoolbox`

| Mode | Equivalent flags after this PR | Wall time | vs software-browser baseline | Speed | Capture | Encode | Output |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Software browser + CPU encode | `--no-browser-gpu` | 120.77s | baseline | 1.17x | 97.87s | 10.04s | 8.38MB |
| Browser GPU + CPU encode | default local render | 70.10s | 42.0% faster | 2.02x | 50.72s | 9.91s | 8.39MB |
| Software browser + encoder GPU | `--no-browser-gpu --gpu` | 133.16s | 10.3% slower | 1.06x | 103.58s | 18.31s | 25.43MB |
| Browser GPU + encoder GPU | `--gpu` | 74.12s | 38.6% faster | 1.91x | 46.69s | 17.93s | 25.45MB |

Result: browser GPU capture is the meaningful improvement for this WebGL/browser-capture-heavy presentation. VideoToolbox encoding was slower and produced larger files for this current `standard` H.264 path, so `--gpu` should stay separate and opt-in.

Why `--gpu` plus browser GPU was slower than browser GPU alone: the combined run captured about 4.0s faster than browser GPU alone, but VideoToolbox encoding was about 8.0s slower than CPU x264 encoding, so the encode loss outweighed the capture gain.

### VideoToolbox flag check

I also isolated the encode stage against the already-captured Apple frames to check whether macOS GPU encoding only needed special flags.

`ffmpeg -h encoder=h264_videotoolbox` does not expose a CRF/CQ-style quality option like x264. It exposes bitrate-oriented and VideoToolbox-specific options such as `-b:v`, `-realtime`, `-profile`, `-coder`, `-prio_speed`, `-power_efficient`, and `-allow_sw`. That means our current `-q:v` mapping is not equivalent to x264 CRF and can produce very different bitrate/size behavior.

Measured full-frame encode variants on this host:

| VideoToolbox variant | Encode wall time | Output size | Bitrate |
| --- | ---: | ---: | ---: |
| Current `-q:v 64 -allow_sw 1` | 18.76s | 25.31MB | 1.43 Mbps |
| Current without `-allow_sw 1` | 18.21s | 25.31MB | 1.43 Mbps |
| `-b:v 500k -maxrate 750k -bufsize 1000k -profile high -coder cabac -realtime 1 -prio_speed 1 -power_efficient 0` | 20.58s | 7.42MB | 0.42 Mbps |
| Same with `-b:v 1500k` | 20.84s | 16.70MB | 0.95 Mbps |
| `-b:v 500k -profile baseline -coder cavlc -realtime 1 -prio_speed 1 -power_efficient 0` | 18.11s | 8.94MB | 0.51 Mbps |

Conclusion: VideoToolbox can be made size/bitrate-predictable with explicit `--video-bitrate`, but the tested speed-oriented flags did not make it faster than CPU x264 wall time for this render. That reinforces keeping `--gpu` encoder acceleration explicit and separate from browser GPU capture.

Artifacts from the local benchmark:

- `/tmp/hf-apple-profile/results/cpu.mp4`
- `/tmp/hf-apple-profile/results/browser-gpu.mp4`
- `/tmp/hf-apple-profile/results/encoder-gpu.mp4`
- `/tmp/hf-apple-profile/results/full-gpu.mp4`
- `/tmp/hf-apple-profile/results/summary.json`

All four benchmark MP4s completed `ffprobe` and full `ffmpeg -f null` decode checks.

### Pixel comparison

Compared decoded MP4 output between software-browser and browser-GPU renders:

- Apple presentation:
  - 4240 frames compared
  - 636 exact matching decoded frame hashes
  - 3604 different decoded frame hashes
  - Average PSNR: 57.79 dB
- `css-spinner-render-compat` clean fixture:
  - 120 frames compared
  - 0 exact matching decoded frame hashes
  - Average PSNR: 61.57 dB

Interpretation: browser GPU output is not strict hash/pixel-identical to the software-browser path after lossy H.264 encode, but the measured deltas are visually tiny. Above 50 dB PSNR is typically visually indistinguishable for normal video review. Use `--no-browser-gpu` or Docker when strict cross-run/cross-machine reproducibility matters more than local speed.

### Browser verification

- Started HyperFrames Studio preview for `packages/producer/tests/css-spinner-render-compat/src`.
- Used `agent-browser` to open `http://localhost:5191#project/src` and verify the composition loaded in Studio.
- Screenshots:
  - `/tmp/hf-gpu-browser-proof/preview-loaded.png`
  - `/tmp/hf-gpu-browser-proof/preview-playing.png`
  - `/tmp/hf-gpu-browser-proof/preview-frame-60.png`
- Agent-browser recordings:
  - `/tmp/hf-gpu-browser-proof/preview-playback.webm`
  - `/tmp/hf-gpu-browser-proof/preview-seek.webm`

## Notes

- Browser GPU is enabled automatically for local CLI renders and disabled in Docker.
- `--no-browser-gpu` is the opt-out for software Chrome/WebGL capture.
- `--gpu` remains encoder-only and opt-in.
- The Apple presentation zip has existing lint errors around unmanaged nested videos and imperative media `play()` calls. The benchmark still compares the same supplied source across modes, but it should not be treated as a clean deterministic-composition fixture.
2026-04-30 06:46:14 +02:00

373 lines
14 KiB
Plaintext

---
title: "@hyperframes/producer"
description: "Full HTML-to-video rendering pipeline with encoding, audio mixing, and Docker support."
---
The producer package combines the [engine's](/packages/engine) frame capture with FFmpeg encoding to deliver a complete HTML-to-video rendering pipeline. It supports MP4 (h264) and WebM (VP9 with alpha transparency), and handles runtime injection, readiness gates, audio mixing, and optional Docker-based deterministic rendering.
```bash
npm install @hyperframes/producer
```
## When to Use
**Use `@hyperframes/producer` when you need to:**
- Render compositions to MP4 or WebM programmatically from Node.js (e.g., in a backend service or CI pipeline)
- Build a custom rendering service with fine-grained control over the pipeline
- Run visual regression tests against golden baselines
- Benchmark render performance across different configurations
**Use a different package if you want to:**
- Render from the command line without writing code — use the [CLI](/packages/cli) (`npx hyperframes render`)
- Preview compositions in the browser — use the [CLI](/packages/cli) or [studio](/packages/studio)
- Capture frames without encoding — use the [engine](/packages/engine)
- Lint or parse composition HTML — use [core](/packages/core)
<Tip>
If you are building a web application or script that just needs to render a video, the [CLI](/packages/cli) is the fastest path. The producer package is for when you need programmatic control inside Node.js.
</Tip>
## What It Does
The producer orchestrates the full render pipeline:
<Steps>
<Step title="Load the composition HTML">
Reads your `index.html` and any referenced sub-compositions.
</Step>
<Step title="Inject the Hyperframes runtime">
Adds the runtime script that manages timeline seeking, clip lifecycle, and media playback.
</Step>
<Step title="Wait for readiness gates">
Polls for `window.__playerReady` and `window.__renderReady` to ensure all assets (fonts, images, video) are loaded before capture begins.
</Step>
<Step title="Capture frames via the engine">
Uses the [engine's](/packages/engine) BeginFrame pipeline to capture each frame as a pixel buffer.
</Step>
<Step title="Encode to MP4 or WebM via FFmpeg">
Pipes frame buffers into FFmpeg with the selected quality preset. MP4 uses h264; WebM uses VP9 with alpha transparency support.
</Step>
<Step title="Mix audio tracks">
Extracts audio from video clips and audio elements, applies `data-volume` and `data-media-start` offsets, and mixes them into the final MP4.
</Step>
</Steps>
## Programmatic Usage
The producer uses a two-step API: create a render job configuration, then execute it.
```typescript
import { createRenderJob, executeRenderJob } from '@hyperframes/producer';
const job = createRenderJob({
input: './my-video/index.html',
output: './output.mp4',
fps: 30,
quality: 'standard',
});
const result = await executeRenderJob(job);
```
### Render Configuration
```typescript
import type { RenderConfig } from '@hyperframes/producer';
const config: RenderConfig = {
fps: 30, // 24, 30, or 60
quality: 'standard', // 'draft', 'standard', or 'high'
format: 'mp4', // 'mp4' or 'webm' (WebM renders with transparency)
workers: 4, // Parallel render workers (1-8)
useGpu: false, // GPU-accelerated encoding
debug: false, // Debug logging
};
```
#### WebM with Transparency
Set `format: 'webm'` to render with a transparent background using VP9 alpha:
```typescript
const job = createRenderJob({
fps: 30,
quality: 'standard',
format: 'webm',
});
await executeRenderJob(job, './my-overlay', './overlay.webm');
```
When `format: 'webm'`:
- Frames are captured as PNG (preserves alpha channel)
- Chrome's page background is set to transparent via CDP
- FFmpeg encodes with VP9 + `yuva420p` pixel format
- Audio is encoded as Opus (instead of AAC for MP4)
#### HDR Output
Set `hdr: true` to enable HDR detection. The producer probes every video and image source for BT.2020 / PQ / HLG color tagging — if any HDR source is found, the output uses H.265 10-bit BT.2020 with HDR10 static metadata. SDR-only compositions are unaffected.
```typescript
const job = createRenderJob({
fps: 30,
quality: 'standard',
format: 'mp4',
hdr: true,
});
await executeRenderJob(job, './my-video', './output.mp4');
```
When `hdr: true`:
- Sources are probed via `ffprobe`; PQ takes precedence over HLG when both are present
- HDR videos and images are extracted as 16-bit linear-light pixels and composited natively
- SDR DOM overlays are converted from sRGB → BT.2020 before being layered on top
- Output uses `libx265` with `yuv420p10le` and HDR10 mastering / content-light-level metadata
- `format` must be `'mp4'` — `'mov'` and `'webm'` fall back to SDR
- HDR `<img>` support is **still images only**; animated HDR-tagged images use only the first frame
For full details on source requirements, fallback rules, and verification, see [HDR Rendering](/guides/hdr).
### Progress Callbacks
```typescript
import type { ProgressCallback, RenderStatus } from '@hyperframes/producer';
const onProgress: ProgressCallback = (status: RenderStatus) => {
console.log(`Status: ${status}`);
// Statuses: "queued" | "preprocessing" | "rendering" | "encoding"
// | "assembling" | "complete" | "failed" | "cancelled"
};
```
### Cancellation
```typescript
import { RenderCancelledError } from '@hyperframes/producer';
try {
await executeRenderJob(job);
} catch (err) {
if (err instanceof RenderCancelledError) {
console.log(`Cancelled: ${err.reason}`);
// reason: "user_cancelled" | "timeout" | "aborted"
}
}
```
## HTTP Server
The producer includes a built-in HTTP server for running as a rendering service:
```typescript
import { startServer } from '@hyperframes/producer/server';
await startServer({ port: 8080 });
```
### Server Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/render` | Blocking render — returns JSON result |
| `POST` | `/render/stream` | Streaming render with Server-Sent Events |
| `POST` | `/lint` | Lint a composition for issues |
| `GET` | `/health` | Health check |
| `GET` | `/outputs/:token` | Download a rendered MP4 |
For custom server integration, use the lower-level handlers:
```typescript
import { createRenderHandlers, createProducerApp } from '@hyperframes/producer/server';
// Get individual request handlers
const handlers = createRenderHandlers(options);
// Or get a full Hono app
const app = createProducerApp(options);
```
## Docker Rendering
For deterministic output, the producer can render inside a Docker container with a pinned Chrome version and font set. This guarantees identical output across machines — critical for CI pipelines and production services.
```bash
# Via the CLI (recommended)
npx hyperframes render --docker --output output.mp4
```
<Info>
Docker mode requires Docker to be installed and running. Run `npx hyperframes doctor` to verify your environment. See [Deterministic Rendering](/concepts/determinism) for details on what makes Docker mode deterministic.
</Info>
## Quality Presets
| Preset | Resolution | Encoding | Use Case |
|--------|-----------|----------|----------|
| `draft` | Original | Fast CRF | Quick iteration, previewing edits |
| `standard` | Original | Balanced CRF | Production renders, sharing |
| `high` | Original | High-quality CRF | Final delivery, archival |
## GPU Encoding
The producer supports hardware-accelerated encoding for faster renders:
| Platform | Encoder | Selection |
|----------|---------|-----------|
| NVIDIA | NVENC | Auto-detected |
| macOS | VideoToolbox | Auto-detected |
| Linux | VAAPI | Auto-detected |
| Intel | QSV | Auto-detected |
When GPU encoding is enabled, Hyperframes detects the available FFmpeg hardware encoder automatically. To check your system's capabilities:
```bash
npx hyperframes doctor
```
The CLI enables local Chrome/WebGL GPU capture automatically and supports `--no-browser-gpu` as an opt-out. When using the producer API directly, pass an engine config override:
```typescript
import { resolveConfig } from '@hyperframes/producer';
const job = createRenderJob({
fps: 30,
quality: 'standard',
producerConfig: resolveConfig({ browserGpuMode: 'hardware' }),
});
```
## Additional Exports
The producer also re-exports key engine functionality for convenience:
| Export | Description |
|--------|-------------|
| `createCaptureSession()` | Create a frame capture session |
| `initializeSession()` | Initialize session with a composition |
| `captureFrame()` / `captureFrameToBuffer()` | Capture individual frames |
| `closeCaptureSession()` | Clean up a capture session |
| `getCompositionDuration()` | Get total composition duration |
| `getCapturePerfSummary()` | Get capture performance metrics |
| `createFileServer()` | Create an HTTP file server for serving assets |
| `createVideoFrameInjector()` | Create a video frame injector for page |
| `resolveConfig()` / `DEFAULT_CONFIG` | Producer configuration |
| `createConsoleLogger()` / `defaultLogger` | Logging utilities |
| `quantizeTimeToFrame()` | Convert time to frame boundary |
| `resolveRenderPaths()` | Resolve render directory paths |
| `prepareHyperframeLintBody()` / `runHyperframeLint()` | Linting utilities |
## Logging
The producer ships a small pluggable logger so callers can inject Pino, Winston, or any structured backend without taking a dependency on it.
```ts
export type LogLevel = "error" | "warn" | "info" | "debug";
export interface ProducerLogger {
error(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
isLevelEnabled?(level: LogLevel): boolean;
}
```
`createConsoleLogger(level)` returns a console-backed implementation that filters by level and JSON-stringifies the optional `meta` object. `defaultLogger` is the singleton at `level="info"`.
### Skipping expensive metadata in hot paths
`isLevelEnabled` is **optional** so existing custom loggers keep working unchanged. When you build a non-trivial meta object in a hot loop just to attach to a debug log, gate the construction with the nullish-coalescing pattern so production runs (`level=info`) pay nothing while loggers without the method behave exactly as before:
```ts
// Inside a per-frame loop in the encode pipeline:
if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
const hdrEl = stackingInfo.find((e) => e.isHdr);
log.debug("[Render] HDR layer composite frame", {
frame: i,
time: time.toFixed(2),
hdrElement: hdrEl
? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width }
: null,
stackingCount: stackingInfo.length,
activeTransition: activeTransition?.shader,
});
}
```
The `?? true` fallback means callers using a custom logger that does not implement `isLevelEnabled` continue to build and pass the meta object — the optimization is opt-in for logger implementations that want it.
## Regression Testing
The producer includes a regression harness for comparing render output against golden baselines. This is useful for catching visual regressions when changing the runtime, engine, or rendering pipeline.
```bash
cd packages/producer
# Build the test Docker image
bun run docker:build:test
# Run regression tests (compares output against golden baselines)
bun run docker:test
# Regenerate golden baselines after intentional changes
bun run docker:test:update
```
## Benchmarking
Find optimal render settings for your hardware:
```bash
# Via the CLI
npx hyperframes benchmark
# Directly from the producer package
cd packages/producer
bun run benchmark
```
The benchmark runs several compositions with different quality and FPS settings and reports timing for each combination.
## External assets (files outside `projectDir`)
A composition can reference absolute paths to assets outside the project
directory — a local voiceover in `~/Downloads`, a shared-drive image, a
generated fixture at an absolute path. The producer handles these by:
1. **Detection.** During compilation, the HTML compiler walks every
`[src]` / `[href]` and every `url(...)` in `<style>`. A path that
resolves to a file outside `projectDir` is collected into an
`externalAssets` map.
2. **Sanitised keys.** Each absolute path is converted into a safe,
cross-platform relative key prefixed with `hf-ext/`. Windows
drive-letter colons are stripped (`D:\foo\x.wav` → `hf-ext/D/foo/x.wav`)
so that `path.join(compileDir, key)` stays inside the compile
directory on every OS.
3. **Copy + rewrite.** The orchestrator copies the file under
`<compileDir>/hf-ext/...` and the HTML is rewritten to point at the
sanitised key. The file server then serves both project-internal and
external assets from the same root.
The containment check uses `path.relative()` rather than a hardcoded
separator, so external assets work identically on macOS, Linux, and
Windows. See `packages/producer/src/utils/paths.ts` for the helpers.
## Related Packages
<CardGroup cols={2}>
<Card title="CLI" icon="terminal" href="/packages/cli">
Command-line interface that wraps the producer for rendering, previewing, and more.
</Card>
<Card title="Engine" icon="gear" href="/packages/engine">
The low-level capture pipeline that the producer uses to grab frames.
</Card>
<Card title="Core" icon="cube" href="/packages/core">
Types, runtime, and linter that the producer depends on.
</Card>
<Card title="Studio" icon="palette" href="/packages/studio">
Visual editor for building compositions before rendering with the producer.
</Card>
</CardGroup>