---
@@ -29,6 +31,8 @@ OpenMotion is a high-performance, open-source alternative to Remotion. It allows
## ✨ Features
- ⚛️ **React-First**: Use the full power of the React ecosystem.
+- 🤖 **AI-Powered Generation**: Create entire videos from text descriptions using LLMs.
+- ✍️ **AI-Assisted Editing**: Edit your TSX scenes using natural language.
- ⏱️ **Frame-Perfect Determinism**: Advanced time-hijacking ensures every frame is identical.
- 🚀 **Parallel Rendering**: Scale your rendering speed by utilizing all CPU cores.
- 🎵 **Multi-track Audio Mixing**: Support for multiple `` with independent volume.
@@ -37,73 +41,15 @@ OpenMotion is a high-performance, open-source alternative to Remotion. It allows
- 💬 **Caption System**: Automated subtitle rendering with SRT support and TikTok-style animations.
- 📊 **Media Analysis**: Dynamic metadata extraction for video/audio (duration, dimensions).
- 📹 **Offthread Video**: High-performance video decoding moved to background processes.
-- 📊 **Dynamic Metadata**: Calculate video dimensions, duration, and other properties dynamically based on input props.
-- 🎬 **GIF & Video Output**: Render to both MP4 video and GIF formats with automatic format detection.
-### 4. 渲染视频 (正式出片)
-
-推荐使用项目自带的 `render` 脚本进行渲染,它会自动完成 **构建 -> 启动静态服务 -> 渲染 -> 自动清理** 的全套流程,确保渲染过程极其稳健,不会因开发服务器缓冲区问题而卡死。
-
-```bash
-# 执行一键渲染 (默认输出 ./out.mp4,开启 4 线程并行)
-npm run render
-
-# 修改输出文件名或指定合成 ID (通过 -- 透传参数)
-npm run render -- -o my-video.mp4 -c main
-```
-
-## 💡 最佳实践
-
-### 稳健渲染方案
-生产环境建议始终优先使用 `npm run render`。该命令内部使用了静态服务模式,彻底告别渲染卡死。
-
-### 参数透传技巧
-你可以通过 `npm run render -- [更多参数]` 覆盖脚本中的默认值:
-- **修改并发数**: `npm run render -- -j 8`
-- **指定 Chromium 路径**: `npm run render -- --chromium-path "/path/to/chrome"`
-
-### 资源存放
-所有本地图片/视频资源请放在 `public/` 目录下,在代码中通过 `/filename` 路径引用。
-
-## 🎬 输出格式支持
-- **.mp4**: 标准视频,包含音频。
-- **.webm**: 支持透明背景的高质量视频。
-- **.gif**: 动态图片,不含音频。
-- **.webp**: 现代动图格式,体积更小,质量更好。
-
-## 🛡️ 特色功能
-- 🛡️ **Pre-Flight Checks**: 内置浏览器安装检查与环境验证。
-- 🌍 **Custom Chromium Path**: 支持通过 `--chromium-path` 参数自定义浏览器路径。
-- 🚀 **Turbo Render**: 一键自动化构建与全自动渲染链条。
-
-## 📚 API Reference
-
-Calculate video properties dynamically:
-
-```tsx
- {
- const meta = await getVideoMetadata(props.src);
- return {
- width: meta.width,
- height: meta.height,
- durationInFrames: Math.ceil(meta.durationInSeconds * 30)
- };
- }}
-/>
-```
+- 📊 **Dynamic Metadata**: Calculate video dimensions, duration, and other properties dynamically.
+- 🎬 **GIF & Video Output**: Render to MP4, WebM, GIF, and WebP formats.
## 📦 Packages
| Package | Description |
| :--- | :--- |
-| [`@open-motion/core`](./packages/core) | React primitives (`Composition`, `Sequence`, `Loop`), hooks, and media utils (`getVideoMetadata`, `parseSrt`). |
-| [`@open-motion/components`](./packages/components) | High-level components (`Transition`, `ThreeCanvas`, `Lottie`, `Captions`, `TikTokCaption`). |
+| [`@open-motion/core`](./packages/core) | React primitives (`Composition`, `Sequence`, `Loop`), hooks, and media utils. |
+| [`@open-motion/components`](./packages/components) | High-level components (`Transition`, `ThreeCanvas`, `Lottie`, `Captions`). |
| [`@open-motion/renderer`](./packages/renderer) | Playwright-based capture engine. |
| [`@open-motion/cli`](./packages/cli) | Command-line interface. |
@@ -113,205 +59,165 @@ Calculate video properties dynamically:
npm install @open-motion/core @open-motion/components
```
-## 🚀 Quick Start
+## 🔧 Building from Source
-### Installation
+To build from source, you'll need [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/).
```bash
-# Install CLI tools globally
-pnpm install -g @open-motion/cli @open-motion/renderer
-
-# Install Playwright browsers (required for rendering)
-npx playwright install chromium
+git clone https://github.com/jsongo/open-motion.git
+cd open-motion
+pnpm install
+pnpm build
```
-### Create & Run Your First Project
+### Windows: Setting up pnpm global link
-```bash
-# Create a new project
-open-motion init fun-video
-cd fun-video && pnpm install
+If you want to use `pnpm link --global` on Windows, you may need to set up the global bin directory first:
-# Start development server
-# Run this in one terminal - it will show the port (e.g. 5173)
-pnpm run dev
+```powershell
+$env:PNPM_HOME = "C:\Users\\AppData\Local\pnpm"
+$env:PATH += ";$env:PNPM_HOME"
+cd packages/cli
+pnpm link --global
```
-**Note**: Keep this terminal open. If port 5173 is in use, Vite will automatically try 5174, 5175, etc. Check the output for the actual port number.
-
-### Render Your Video
+Alternatively, run `pnpm setup` and restart your terminal to apply the environment variables automatically.
-In another terminal, render your project using the port from above:
+## 🚀 Quick Start
+### 1. Setup
+Install CLI tools and required browsers:
```bash
-# Render to MP4 (14 seconds at 30fps)
-open-motion render -u http://localhost:5173 -o out.mp4 --duration 420
-
-# Render to GIF (14 seconds at 30fps)
-open-motion render -u http://localhost:5173 -o out.gif --duration 420
-
-# Render to WebP (better quality than GIF)
-open-motion render -u http://localhost:5173 -o out.webp --duration 420
-
-# Render to WebM (transparent video support)
-open-motion render -u http://localhost:5173 -o out.webm --duration 420
+pnpm install -g @open-motion/cli @open-motion/renderer
+npx playwright install chromium
```
-**Duration explained**: `--duration 420` means 420 frames. At 30fps, that's 420 ÷ 30 = **14 seconds** of video.
-
-### Create a Composition
+If Japanese/Chinese/Korean text renders as squares in headless Linux, it's usually missing system fonts. Either install CJK fonts (recommended) or load a local font at render time.
-```tsx
-import { Composition, useCurrentFrame, interpolate } from "@open-motion/core";
-import { Transition, TikTokCaption } from "@open-motion/components";
+- Install system fonts (Ubuntu/Debian): `sudo apt-get update && sudo apt-get install -y fonts-noto-cjk`
+- Or load a local font file: `open-motion render ... --font "Noto Sans JP=./public/fonts/NotoSansJP-Regular.woff2"`
-const MyScene = () => {
- const frame = useCurrentFrame();
- return (
-
-
-
-
-
- );
-};
+### 2. Create Project
+```bash
+mkdir -p my_videos && cd my_videos
+open-motion init my-video1
+cd my-video1
+pnpm install
```
-**Note about ports**: If port 5173 is already in use, Vite will automatically try 5174, 5175, etc. Check the dev server output for the actual port number (e.g., "Local: http://localhost:5177/").
-
-## 📚 API Reference
-
-Complete reference for all OpenMotion features and components.
-
-### Core Hooks
-
-**`useCurrentFrame()`**
-Get the current frame number in your animation.
-
-```tsx
-const frame = useCurrentFrame();
-const opacity = interpolate(frame, [0, 30], [0, 1]);
-```
+### 3. Develop & Render
-**`useVideoConfig()`**
-Access video configuration (width, height, fps, durationInFrames).
+Start the dev server in one terminal:
-```tsx
-const { width, height, fps } = useVideoConfig();
+```bash
+pnpm run dev
```
-### Animation & Transitions
-
-**``**
-Create looping time contexts for sub-animations.
-
-```tsx
-
-
-
+In another terminal, render your video using the server URL:
+```bash
+open-motion render -u http://localhost:5173 -o out.mp4 --composition my-video1
```
-**``**
-Smooth enter/exit transitions. Types: `fade`, `wipe`, `slide`, `zoom`.
-
-```tsx
-
-
-
-```
+## 💻 CLI Reference
-**`Easing.inOutExpo`**
-Complete library of easing functions:
-- `Easing.linear`, `Easing.easeIn`, `Easing.easeOut`, `Easing.easeInOut`
-- `Easing.inOutCubic`, `Easing.outBack`, `Easing.inExpo`, and more
+### `open-motion init `
+Initialize a new OpenMotion project with a pre-configured React template.
-```tsx
-const value = interpolate(frame, [0, 30], [0, 100], {
- easing: Easing.outCubic,
-});
-```
+### `open-motion generate `
+Automatically generate video scenes and code from a text description using an LLM.
-### 3D & Lottie Integration
+| Option | Description |
+| :--- | :--- |
+| `--env ` | Path to .env file (default: .env in current directory) |
+| `--scenes ` | Number of scenes to generate |
+| `--fps ` | Frames per second (default: 30) |
+| `--width ` | Video width (default: 1280) |
+| `--height ` | Video height (default: 720) |
-**``**
-Render Three.js scenes synced with video frames. See `packages/components` for details.
+### `open-motion edit `
+Edit a TSX scene file using natural language instructions.
-**``**
-Declarative Lottie animations with frame-accurate control.
+| Option | Description |
+| :--- | :--- |
+| `--env ` | Path to .env file (default: .env in current directory) |
+| `-m, --message ` | Instruction for editing |
+| `-y, --yes` | Auto-apply changes (one-shot mode) |
-```tsx
-
-```
+### `open-motion config`
+Manage LLM provider settings (API keys, models).
-### Media & Captions
+- `open-motion config list`
+- `open-motion config get `
-**``**
-Multi-track audio support with independent volume and timing.
+LLM settings are read from environment variables (you can put them in a project-local `.env` file):
-```tsx
-
+```bash
+# .env
+OPEN_MOTION_PROVIDER=openai
+OPEN_MOTION_MODEL=gpt-5.1
+OPENAI_API_KEY=sk-...
```
-**`parseSrt(srtContent)`**
-Convert SRT subtitle files to arrays.
-
-```tsx
-const subtitles = parseSrt(await fetch('/subtitles.srt').then(r => r.text()));
-```
+### `open-motion render`
+Render a video from a running OpenMotion application.
-**``**
-Flexible subtitle renderer with styling options.
+| Option | Description |
+| :--- | :--- |
+| `-u, --url ` | **Required.** URL of the OpenMotion app (e.g., `http://localhost:5173`) |
+| `-o, --out ` | **Required.** Output file path (e.g., `out.mp4`, `animation.gif`) |
+| `-c, --composition ` | ID of the composition to render |
+| `-p, --props ` | JSON string of props to pass to the composition |
+| `-j, --concurrency ` | Number of parallel browser instances (default: 1) |
+| `--format ` | Output format: `mp4`, `webm`, `gif`, `webp`, `auto` |
+| `--width ` | Override output width |
+| `--height ` | Override output height |
+| `--fps ` | Override frames per second |
+| `--duration ` | Override total frames to render |
+| `--public-dir ` | Public directory for static assets (default: `./public`) |
+| `--chromium-path `| Path to custom Chromium executable |
+| `--timeout ` | Timeout for browser operations in ms |
+| `--font ` | Load a local font file for rendering (repeatable). Format: `Family=path` or just `path` |
+| `--bgm ` | Add a background music track from a local MP3 file |
+| `--bgm-volume ` | BGM volume (0.0-1.0, default: 1.0) |
+
+Example (render-time BGM):
-```tsx
-
+```bash
+open-motion render -u http://localhost:5173 -o out.mp4 --bgm ./music/bgm.mp3 --bgm-volume 0.5
```
-**``**
-Pre-styled component for TikTok-like animated captions.
+Notes:
+- If the BGM is shorter than the video, it will loop to cover the full duration.
+- If the BGM is longer than the video, it will be trimmed to the video duration.
-**`getVideoMetadata(url)`**
-Fetch video dimensions and duration.
+## 📚 API Reference
-```tsx
-const { width, height, durationInSeconds } = await getVideoMetadata('/video.mp4');
-```
+### Core Hooks & Configuration
+**`useCurrentFrame()`**: Get the current frame number.
+**`useVideoConfig()`**: Access width, height, fps, and duration.
-**``**
-High-performance video decoding in background processes.
+### Components
+- **``**: Create looping time contexts.
+- **``**: Smooth enter/exit effects (`fade`, `wipe`, `slide`, `zoom`).
+- **``**: Render synced Three.js scenes.
+- **``**: Declarative Lottie animations.
+- **``**: Multi-track audio with volume control.
+- **``** / **``**: Subtitle rendering.
+- **``**: High-performance background video decoding.
-### Output & Export Options
+### Utilities
+- **`interpolate()`**: Map ranges with easing support.
+- **`Easing`**: Complete library of easing functions.
+- **`parseSrt()`**: Convert SRT files to data structures.
+- **`getVideoMetadata()`**: Fetch dimensions and duration of video files.
-**CLI Commands**
+## 💡 Best Practices
-```bash
-# Basic rendering
-open-motion render -u http://localhost:5173 -o video.mp4
-
-# With custom settings
-open-motion render -u http://localhost:5173 -o video.mp4 \
- --duration 420 \
- --width 1920 \
- --height 1080 \
- --fps 30
-
-# Render to GIF
-open-motion render -u http://localhost:5173 -o animation.gif \
- --duration 420 \
- --public-dir ./public
-```
+### Robust Rendering
+For production, use the project's built-in `npm run render` script. It handles the full **build -> static server -> render -> cleanup** pipeline, eliminating buffer issues.
-**File Formats**
-- **MP4**: Full video with audio support (H.264)
-- **WebM**: Web-optimized video with transparency support (VP9)
-- **GIF**: Lightweight animations (no audio)
-- **WebP**: High-quality animated images (better than GIF, no audio)
-
-**Quality Parameters**
-- `--width`: Output width in pixels
-- `--height`: Output height in pixels
-- `--fps`: Frames per second (default: 30)
-- `--duration`: Total frames (e.g., 420 = 14 seconds at 30fps)
-- `--format`: Explicit format (mp4, webm, gif, webp, auto)
+### Asset Storage
+Place all local assets in `public/` and reference them via absolute paths (e.g., `/video.mp4`).
## 📜 License
diff --git a/README.zh.md b/README.zh.md
new file mode 100644
index 0000000..48abb61
--- /dev/null
+++ b/README.zh.md
@@ -0,0 +1,224 @@
+# OpenMotion
+
+
);
};
+const RenderMode = () => {
+ const frame = (window as any).__OPEN_MOTION_FRAME__ as number;
+ const compositionId = ((window as any).__OPEN_MOTION_COMPOSITION_ID__ as string | undefined) ?? 'main';
+ const inputProps = (window as any).__OPEN_MOTION_INPUT_PROPS__ ?? {};
+
+ const selected = getCompositionById(compositionId) ?? getCompositions()[0];
+ if (!selected) return null;
+
+ const config: VideoConfig = {
+ width: selected.width,
+ height: selected.height,
+ fps: selected.fps,
+ durationInFrames: selected.durationInFrames,
+ };
+
+ const Component = selected.component;
+ return (
+
+
+
+ );
+};
+
+const Root = () => {
+ const isRendering = typeof (window as any).__OPEN_MOTION_FRAME__ === 'number';
+ return (
+ <>
+
+ {isRendering ? : }
+ >
+ );
+};
+
ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -175,6 +311,9 @@ export const runRender = async (options: {
format?: 'mp4' | 'gif' | 'webp' | 'webm' | 'auto';
chromiumPath?: string;
timeout?: number;
+ bgm?: string;
+ bgmVolume?: number;
+ font?: string[];
}) => {
const timeout = options.timeout || parseInt(process.env.OPEN_MOTION_RENDER_TIMEOUT || '300000', 10);
@@ -185,6 +324,24 @@ export const runRender = async (options: {
const inputProps = options.props ? JSON.parse(options.props) : {};
const startTime = Date.now();
+ const parseFontSpecs = (specs?: string[]): RenderFont[] => {
+ if (!specs || specs.length === 0) return [];
+
+ return specs
+ .map((raw) => String(raw).trim())
+ .filter(Boolean)
+ .map((raw) => {
+ const eq = raw.indexOf('=');
+ if (eq === -1) {
+ return { family: 'OpenMotionFont', path: raw };
+ }
+ const family = raw.slice(0, eq).trim() || 'OpenMotionFont';
+ const p = raw.slice(eq + 1).trim();
+ return { family, path: p };
+ })
+ .filter((f) => Boolean(f.path));
+ };
+
// Check for browser installation before starting
try {
chromium.executablePath();
@@ -255,7 +412,8 @@ export const runRender = async (options: {
concurrency: options.concurrency || 1,
publicDir: options.publicDir ? path.join(process.cwd(), options.publicDir) : undefined,
onProgress: (frame) => renderBar.update(frame),
- timeout
+ timeout,
+ fonts: parseFontSpecs(options.font),
});
renderBar.update(config.durationInFrames);
@@ -304,6 +462,24 @@ export const runRender = async (options: {
return asset;
});
+ // Inject a render-time BGM track (file path on disk)
+ if (options.bgm) {
+ const bgmPath = path.resolve(process.cwd(), options.bgm);
+
+ if (!fs.existsSync(bgmPath)) {
+ console.error(`BGM file not found: ${bgmPath}`);
+ process.exit(1);
+ }
+
+ resolvedAudioAssets.push({
+ src: bgmPath,
+ startFrame: 0,
+ startFrom: 0,
+ volume: options.bgmVolume ?? 1.0,
+ isBgm: true,
+ });
+ }
+
const encodeBar = multibar.create(100, 0, { task: 'Encoding ' });
// Determine output format
@@ -340,6 +516,7 @@ export const runRender = async (options: {
framesDir: tmpDir,
fps: config.fps,
outputFile: options.out,
+ durationInFrames: config.durationInFrames,
audioAssets: resolvedAudioAssets,
onProgress: (percent) => encodeBar.update(Math.round(percent))
});
@@ -360,6 +537,7 @@ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'),
export const main = () => {
const program = new Command();
+ const pm = getPackageManager();
program
.name('open-motion')
@@ -369,12 +547,18 @@ export const main = () => {
Quick Start:
1. Initialize project: $ open-motion init my-video
2. Enter directory: $ cd my-video
- 3. Install deps: $ pnpm install
- 4. Start dev server: $ pnpm dev
- 5. Render video: $ pnpm render
+ 3. Install deps: $ ${pm} install
+ 4. Configure LLM: $ open-motion config set provider openai
+ $ open-motion config set openai.apiKey sk-...
+ 5. Generate scenes: $ open-motion generate "A video explaining the React lifecycle"
+ 6. Edit a scene: $ open-motion edit src/scenes/IntroScene.tsx
+ 7. Start dev server: $ ${formatRun(pm, 'dev')}
+ 8. Render video: $ ${formatRun(pm, 'render')}
Example Usage:
$ open-motion init my-project
+ $ open-motion generate "TypeScript type system explainer"
+ $ open-motion edit src/scenes/IntroScene.tsx --message "Change the background to blue"
$ open-motion render -u http://localhost:5173 -o output.mp4 --composition main
`);
@@ -406,6 +590,17 @@ Example Usage:
.option('--format ', 'Output format (mp4, webm, gif, webp, auto)', 'auto')
.option('--chromium-path ', 'Custom path to Chromium executable')
.option('--timeout ', 'Timeout for browser operations in milliseconds', parseInt)
+ .option(
+ '--font ',
+ 'Load a local font file for rendering (repeatable). Format: "Family=path" or just "path".',
+ (value, previous: string[] = []) => {
+ previous.push(value);
+ return previous;
+ },
+ [] as string[]
+ )
+ .option('--bgm ', 'Path to an MP3 file to use as background music')
+ .option('--bgm-volume ', 'BGM volume (0.0-1.0, default: 1.0)', parseFloat)
.action(async (options) => {
try {
await runRender({
@@ -421,7 +616,10 @@ Example Usage:
publicDir: options.publicDir,
format: options.format,
chromiumPath: options.chromiumPath,
- timeout: options.timeout
+ timeout: options.timeout,
+ font: options.font,
+ bgm: options.bgm,
+ bgmVolume: options.bgmVolume,
});
} catch (err) {
console.error('Render failed:', err);
@@ -436,5 +634,156 @@ Examples:
$ open-motion render -u http://localhost:3000 -o banner.gif --format gif --width 1200 --height 630
`);
+ // ---------------------------------------------------------------------------
+ // generate command
+ // ---------------------------------------------------------------------------
+ const generateCommand = program
+ .command('generate ')
+ .description('Auto-generate video scene TSX files using an LLM')
+ .option('--env ', 'Path to .env file (default: .env in current directory)')
+ .option('--api-key ', 'API key (overrides environment variables)')
+ .option('--base-url ', 'Base URL (for openrouter / openai-compatible / ollama)')
+ .option('--scenes ', 'Number of scenes to generate (default: decided by LLM)', parseInt)
+ .option('--fps ', 'Frame rate (default: 30)', parseInt)
+ .option('--width ', 'Video width (default: 1280)', parseInt)
+ .option('--height ', 'Video height (default: 720)', parseInt)
+ .option('--output ', 'Output directory for scene files (default: src/scenes)')
+ .option('--bgm ', 'Path to BGM audio file (MP3/WAV)')
+ .option('--bgm-volume ', 'BGM volume (0.0-1.0, default: 0.5)', parseFloat)
+ .action(async (description: string, options) => {
+ try {
+ await runGenerate(description, {
+ apiKey: options.apiKey,
+ baseURL: options.baseUrl,
+ scenes: options.scenes,
+ fps: options.fps,
+ width: options.width,
+ height: options.height,
+ output: options.output,
+ bgm: options.bgm,
+ bgmVolume: options.bgmVolume,
+ });
+ } catch (err) {
+ console.error('Generate failed:', err);
+ process.exit(1);
+ }
+ });
+
+ generateCommand.addHelpText('after', `
+Examples:
+ $ open-motion generate "A video explaining the React lifecycle"
+ $ open-motion generate "TypeScript type system explainer" --scenes 4 --fps 30
+ $ open-motion generate "CI/CD pipeline explainer" --width 1920 --height 1080
+ $ open-motion generate "How Docker works" --env .env.ollama
+
+Note:
+ - LLM configuration is read from environment variables
+ - By default, .env in the current directory is loaded
+ - Use --env to specify a custom .env file path
+ - Run this command from the root of an existing project (created with open-motion init)
+`);
+
+ // ---------------------------------------------------------------------------
+ // edit command
+ // ---------------------------------------------------------------------------
+ const editCommand = program
+ .command('edit ')
+ .description('Interactively edit a TSX scene file using an LLM')
+ .option('-m, --message ', 'One-shot mode: pass instruction as a string')
+ .option('--env ', 'Path to .env file (default: .env in current directory)')
+ .option('--api-key ', 'API key (overrides environment variables)')
+ .option('--base-url ', 'Base URL (for openrouter / openai-compatible / ollama)')
+ .option('-y, --yes', 'Auto-apply changes without confirmation (one-shot mode only)')
+ .action(async (file: string, options) => {
+ try {
+ await runEdit(file, {
+ message: options.message,
+ apiKey: options.apiKey,
+ baseURL: options.baseUrl,
+ yes: options.yes,
+ });
+ } catch (err) {
+ console.error('Edit failed:', err);
+ process.exit(1);
+ }
+ });
+
+ editCommand.addHelpText('after', `
+Examples:
+ # Interactive mode (edit repeatedly in a conversation)
+ $ open-motion edit src/scenes/IntroScene.tsx
+
+ # One-shot mode (pass a single instruction)
+ $ open-motion edit src/scenes/IntroScene.tsx --message "Change the background to blue"
+ $ open-motion edit src/scenes/IntroScene.tsx -m "Make the text larger" --yes
+ $ open-motion edit src/scenes/IntroScene.tsx -m "Smooth out the animation" --env .env.anthropic
+`);
+
+ // ---------------------------------------------------------------------------
+ // config command
+ // ---------------------------------------------------------------------------
+ const configCommand = program
+ .command('config')
+ .description('Manage LLM provider configuration (~/.open-motion/config.json)')
+ .action(() => {
+ printConfigHelp();
+ });
+
+ configCommand
+ .command('set ')
+ .description('Save a configuration value')
+ .action((key: string, value: string) => {
+ runConfigSet(key, value);
+ });
+
+ configCommand
+ .command('get ')
+ .description('Show a configuration value')
+ .action((key: string) => {
+ runConfigGet(key);
+ });
+
+ configCommand
+ .command('list')
+ .description('List all configuration values')
+ .action(() => {
+ runConfigList();
+ });
+
+ configCommand.addHelpText('after', `
+Configurable keys:
+ provider LLM provider to use (openai/openrouter/anthropic/google/ollama/openai-compatible)
+ model Global model override
+ openai.apiKey OpenAI API key
+ openai.model OpenAI model (default: gpt-4o)
+ openrouter.apiKey OpenRouter API key
+ openrouter.model OpenRouter model (default: openai/gpt-4o)
+ anthropic.apiKey Anthropic API key
+ anthropic.model Anthropic model (default: claude-3-5-sonnet-20241022)
+ google.apiKey Google AI API key
+ google.model Google model (default: gemini-1.5-pro)
+ ollama.baseURL Ollama server URL (default: http://localhost:11434)
+ ollama.model Ollama model (default: llama3)
+ openai-compatible.baseURL Base URL for custom API
+ openai-compatible.apiKey API key for custom API
+ openai-compatible.model Model name for custom API
+
+Environment variables (override config file):
+ OPEN_MOTION_PROVIDER Provider override
+ OPEN_MOTION_MODEL Model override
+ OPENAI_API_KEY OpenAI API key
+ OPENROUTER_API_KEY OpenRouter API key
+ OPENROUTER_BASE_URL OpenRouter base URL override
+ ANTHROPIC_API_KEY Anthropic API key
+ GOOGLE_API_KEY / GEMINI_API_KEY Google AI API key
+ OPEN_MOTION_BASE_URL Custom base URL
+ OPEN_MOTION_API_KEY Custom API key
+
+Examples:
+ $ open-motion config list
+ $ OPEN_MOTION_PROVIDER=openrouter OPENROUTER_API_KEY=sk-or-... open-motion generate "Explain closures"
+ $ open-motion config list
+`);
+
program.parse(process.argv);
};
diff --git a/packages/cli/src/llm/config.ts b/packages/cli/src/llm/config.ts
new file mode 100644
index 0000000..143b894
--- /dev/null
+++ b/packages/cli/src/llm/config.ts
@@ -0,0 +1,109 @@
+import type { ProviderName, ResolvedLLMConfig } from './types';
+import { DEFAULT_MODELS } from './types';
+
+// ---------------------------------------------------------------------------
+// Environment variable helpers
+// ---------------------------------------------------------------------------
+
+function env(key: string): string | undefined {
+ const v = process.env[key];
+ return v && v.trim() !== '' ? v.trim() : undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Config resolution
+// ---------------------------------------------------------------------------
+
+export interface CliConfigOverrides {
+ apiKey?: string;
+ baseURL?: string;
+}
+
+const OPENROUTER_DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
+
+/**
+ * Merge environment variables + CLI overrides into a single ResolvedLLMConfig.
+ * Priority (high → low):
+ * CLI flags > ENV vars (.env file values are loaded as ENV vars by dotenv)
+ */
+export function resolveConfig(overrides: CliConfigOverrides = {}): ResolvedLLMConfig {
+ const provider: ProviderName =
+ (env('OPEN_MOTION_PROVIDER') as ProviderName | undefined) || 'openai';
+
+ const model = env('OPEN_MOTION_MODEL') || DEFAULT_MODELS[provider];
+
+ // 3. API key / base URL (provider-specific)
+ let apiKey: string | undefined;
+ let baseURL: string | undefined;
+
+ switch (provider) {
+ case 'openai':
+ apiKey = overrides.apiKey || env('OPENAI_API_KEY');
+ break;
+ case 'openrouter':
+ apiKey =
+ overrides.apiKey ||
+ env('OPENROUTER_API_KEY') ||
+ // Back-compat: allow users to reuse the generic key env var.
+ env('OPEN_MOTION_API_KEY');
+ baseURL =
+ overrides.baseURL ||
+ env('OPENROUTER_BASE_URL') ||
+ // Back-compat: allow using the generic base URL env var.
+ env('OPEN_MOTION_BASE_URL') ||
+ OPENROUTER_DEFAULT_BASE_URL;
+ break;
+ case 'anthropic':
+ apiKey = overrides.apiKey || env('ANTHROPIC_API_KEY');
+ break;
+ case 'google':
+ apiKey = overrides.apiKey || env('GOOGLE_API_KEY') || env('GEMINI_API_KEY');
+ break;
+ case 'ollama':
+ baseURL =
+ overrides.baseURL ||
+ env('OPEN_MOTION_BASE_URL') ||
+ 'http://localhost:11434';
+ break;
+ case 'openai-compatible':
+ apiKey = overrides.apiKey || env('OPEN_MOTION_API_KEY');
+ baseURL = overrides.baseURL || env('OPEN_MOTION_BASE_URL');
+ break;
+ }
+
+ return { provider, model, apiKey, baseURL };
+}
+
+/**
+ * Validate config and throw a human-readable error if required fields are missing.
+ */
+export function validateConfig(cfg: ResolvedLLMConfig): void {
+ if (cfg.provider === 'ollama') {
+ // Ollama runs locally — no API key needed
+ return;
+ }
+ if (cfg.provider === 'openai-compatible') {
+ if (!cfg.baseURL) {
+ throw new Error(
+ 'openai-compatible provider requires a base URL.\n' +
+ 'Set the OPEN_MOTION_BASE_URL environment variable, or pass --base-url.'
+ );
+ }
+ return;
+ }
+ if (!cfg.apiKey) {
+ const envVar: Record = {
+ openai: 'OPENAI_API_KEY',
+ openrouter: 'OPENROUTER_API_KEY',
+ anthropic: 'ANTHROPIC_API_KEY',
+ google: 'GOOGLE_API_KEY',
+ ollama: '',
+ 'openai-compatible': 'OPEN_MOTION_API_KEY',
+ };
+ throw new Error(
+ `No API key found for provider "${cfg.provider}".\n` +
+ `Set the ${envVar[cfg.provider]} environment variable, or pass --api-key.\n` +
+ `You can also add it to a .env file in your project directory.`
+ );
+ }
+}
diff --git a/packages/cli/src/llm/factory.ts b/packages/cli/src/llm/factory.ts
new file mode 100644
index 0000000..38e207c
--- /dev/null
+++ b/packages/cli/src/llm/factory.ts
@@ -0,0 +1,60 @@
+import type { LanguageModelV1 } from 'ai';
+import type { ResolvedLLMConfig } from './types';
+
+/**
+ * Create a LanguageModelV1 instance from the resolved config.
+ * Each provider SDK is imported lazily so the CLI doesn't fail if a provider's
+ * package is somehow missing (peer-dep install issues, etc.).
+ */
+export async function createModel(cfg: ResolvedLLMConfig): Promise {
+ switch (cfg.provider) {
+ case 'openai': {
+ const { createOpenAI } = await import('@ai-sdk/openai');
+ const client = createOpenAI({ apiKey: cfg.apiKey });
+ return client(cfg.model) as LanguageModelV1;
+ }
+
+ case 'openrouter': {
+ const { createOpenAI } = await import('@ai-sdk/openai');
+ const client = createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL });
+ return client(cfg.model) as LanguageModelV1;
+ }
+
+ case 'anthropic': {
+ const { createAnthropic } = await import('@ai-sdk/anthropic');
+ const client = createAnthropic({ apiKey: cfg.apiKey });
+ return client(cfg.model) as LanguageModelV1;
+ }
+
+ case 'google': {
+ const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
+ const client = createGoogleGenerativeAI({ apiKey: cfg.apiKey });
+ return client(cfg.model) as LanguageModelV1;
+ }
+
+ case 'ollama': {
+ const { ollama: createOllama } = await import('ollama-ai-provider');
+ // ollama-ai-provider exports a pre-configured instance; to set a custom
+ // base URL we create a new one via createOllama (same module, named export).
+ if (cfg.baseURL && cfg.baseURL !== 'http://localhost:11434') {
+ const { createOllama: factory } = await import('ollama-ai-provider');
+ const client = factory({ baseURL: `${cfg.baseURL}/api` });
+ return client(cfg.model) as LanguageModelV1;
+ }
+ return createOllama(cfg.model) as LanguageModelV1;
+ }
+
+ case 'openai-compatible': {
+ const { createOpenAI } = await import('@ai-sdk/openai');
+ const client = createOpenAI({
+ baseURL: cfg.baseURL,
+ apiKey: cfg.apiKey || 'placeholder',
+ });
+ return client(cfg.model) as LanguageModelV1;
+ }
+
+ default: {
+ throw new Error(`Unknown provider: ${(cfg as ResolvedLLMConfig).provider}`);
+ }
+ }
+}
diff --git a/packages/cli/src/llm/types.ts b/packages/cli/src/llm/types.ts
new file mode 100644
index 0000000..0ab2fde
--- /dev/null
+++ b/packages/cli/src/llm/types.ts
@@ -0,0 +1,67 @@
+export type ProviderName =
+ | 'openai'
+ | 'openrouter'
+ | 'anthropic'
+ | 'google'
+ | 'ollama'
+ | 'openai-compatible';
+
+export interface OpenAIConfig {
+ apiKey?: string;
+ model?: string;
+}
+
+export interface AnthropicConfig {
+ apiKey?: string;
+ model?: string;
+}
+
+export interface GoogleConfig {
+ apiKey?: string;
+ model?: string;
+}
+
+export interface OllamaConfig {
+ baseURL?: string;
+ model?: string;
+}
+
+export interface OpenAICompatibleConfig {
+ baseURL?: string;
+ apiKey?: string;
+ model?: string;
+}
+
+/**
+ * Shape stored in ~/.open-motion/config.json
+ */
+export interface OpenMotionLLMConfig {
+ provider?: ProviderName;
+ /** Global model override (takes priority over provider-specific model) */
+ model?: string;
+ openai?: OpenAIConfig;
+ anthropic?: AnthropicConfig;
+ google?: GoogleConfig;
+ ollama?: OllamaConfig;
+ 'openai-compatible'?: OpenAICompatibleConfig;
+}
+
+/**
+ * Fully resolved config after merging file + env vars + CLI flags
+ */
+export interface ResolvedLLMConfig {
+ provider: ProviderName;
+ model: string;
+ apiKey?: string;
+ baseURL?: string;
+}
+
+/** Default models for each provider */
+export const DEFAULT_MODELS: Record = {
+ openai: 'gpt-4o',
+ openrouter: 'openai/gpt-4o',
+ anthropic: 'claude-3-5-sonnet-20241022',
+ google: 'gemini-1.5-pro',
+ ollama: 'llama3',
+ 'openai-compatible': 'gpt-4o',
+};
diff --git a/packages/cli/src/prompts/edit.ts b/packages/cli/src/prompts/edit.ts
new file mode 100644
index 0000000..62b1d41
--- /dev/null
+++ b/packages/cli/src/prompts/edit.ts
@@ -0,0 +1,49 @@
+export const EDIT_SYSTEM_PROMPT = `You are an expert at editing React video animation components written with the open-motion library.
+
+## open-motion API Reference
+
+All animation is driven entirely by the current frame number. NEVER use useState, useEffect,
+setTimeout, setInterval, or any real-time mechanism for animation.
+
+### Core hooks (import from '@open-motion/core')
+- \`useCurrentFrame()\` — returns the current frame number (0-based integer)
+- \`useVideoConfig()\` — returns \`{ width, height, fps, durationInFrames }\`
+
+### Animation utilities (import from '@open-motion/core')
+- \`interpolate(value, inputRange, outputRange, options?)\`
+ - options: \`{ extrapolateLeft?: 'clamp'|'extend', extrapolateRight?: 'clamp'|'extend' }\`
+- \`spring({ frame, fps, config?, from?, to? })\`
+ - config: \`{ stiffness?: number, damping?: number, mass?: number }\`
+
+### Layout component
+- \`\` — children visible only in a frame range
+
+## Editing rules
+1. Keep the same component name and named export
+2. Preserve all inline styles (no CSS files)
+3. Only modify what the user explicitly asks to change
+4. Keep the code compiling and functionally correct
+5. Do NOT add new imports unless they are from \`react\` or \`@open-motion/core\`
+`;
+
+export function buildEditPrompt(fileContent: string, instruction: string): string {
+ return `Here is the current TSX file:
+
+\`\`\`tsx
+${fileContent}
+\`\`\`
+
+Instruction: ${instruction}
+
+Return ONLY the complete updated TSX file content. No markdown code fences, no explanation.`;
+}
+
+/**
+ * Strip markdown code fences from a raw TSX edit response, if present.
+ */
+export function parseEditResponse(text: string): string {
+ return text
+ .replace(/^```(?:tsx?|jsx?|typescript|javascript)?\s*\n?/m, '')
+ .replace(/\n?```\s*$/m, '')
+ .trim();
+}
diff --git a/packages/cli/src/prompts/generate.ts b/packages/cli/src/prompts/generate.ts
new file mode 100644
index 0000000..52bdc11
--- /dev/null
+++ b/packages/cli/src/prompts/generate.ts
@@ -0,0 +1,492 @@
+// ---------------------------------------------------------------------------
+// System prompt shared by all generation calls
+// ---------------------------------------------------------------------------
+export const GENERATE_SYSTEM_PROMPT = `You are an expert at creating React video animation components using the open-motion library.
+
+## open-motion API Reference
+
+All animation is driven entirely by the current frame number. NEVER use useState, useEffect,
+setTimeout, setInterval, or any real-time mechanism for animation.
+
+### Core hooks (import from '@open-motion/core')
+- \`useCurrentFrame()\` — returns the current frame number (0-based integer)
+- \`useVideoConfig()\` — returns \`{ width, height, fps, durationInFrames }\`
+
+### Animation utilities (import from '@open-motion/core')
+- \`interpolate(value, inputRange, outputRange, options?)\`
+ - Maps a value from one range to another (like CSS linear-gradient or Framer motion)
+ - options: \`{ extrapolateLeft?: 'clamp'|'extend', extrapolateRight?: 'clamp'|'extend' }\`
+ - Example: \`interpolate(frame, [0, 30], [0, 1], { extrapolateRight: 'clamp' })\`
+- \`spring({ frame, fps, config?, from?, to? })\`
+ - Returns a spring-animated value (0→1 by default)
+ - config: \`{ stiffness?: number, damping?: number, mass?: number }\`
+ - Example: \`spring({ frame, fps, config: { stiffness: 100, damping: 10 } })\`
+
+### Layout component (import from '@open-motion/core')
+- \`\`
+ - Children are only rendered during frames [from, from+durationInFrames)
+ - Inside Sequence, \`useCurrentFrame()\` returns a frame relative to \`from\`
+
+### Easing functions (import from '@open-motion/core')
+- \`Easing.linear\`, \`Easing.easeIn\`, \`Easing.easeOut\`, \`Easing.easeInOut\`
+- \`Easing.inOutCubic\`, \`Easing.outBack\`, \`Easing.inExpo\`, \`Easing.outExpo\`, and more
+- Example: \`interpolate(frame, [0, 30], [0, 100], { easing: Easing.outCubic })\`
+
+### Animation & Transitions (import from '@open-motion/components')
+- \`\` — Create looping time contexts for sub-animations
+ - Example: \`\`
+- \`\` — Smooth enter/exit transitions
+ - Example: \`\`
+
+### Media components (import from '@open-motion/core')
+- \`\` — Multi-track audio with independent volume and timing
+ - Example: \`\`
+- \`\` — High-performance video decoding in background processes
+- \`getVideoMetadata(url)\` — Fetch video dimensions and duration (async)
+ - Example: \`const { width, height, durationInSeconds } = await getVideoMetadata('/video.mp4');\`
+- \`parseSrt(srtContent)\` — Convert SRT subtitle files to arrays
+ - Example: \`const subtitles = parseSrt(await fetch('/subtitles.srt').then(r => r.text()));\`
+
+### Caption components (import from '@open-motion/components')
+- \`\` — Flexible subtitle renderer with styling options
+- \`\` — Pre-styled component for TikTok-like animated captions
+
+### 3D & Lottie (import from '@open-motion/components')
+- \`\` — Render Three.js scenes synced with video frames
+- \`\` — Declarative Lottie animations with frame-accurate control
+ - Example: \`\`
+
+## Coding rules
+1. Export the component as a **named export** (not default)
+2. All styles must be **inline React styles** (no CSS files, no styled-components)
+3. The root element MUST fill the full \`width\` and \`height\` from \`useVideoConfig()\`
+4. Never import anything outside of \`react\`, \`@open-motion/core\`, and \`@open-motion/components\`
+5. Do NOT add \`\` registration inside scene files — that belongs in main.tsx
+6. The component must be **completely self-contained** with no props required
+7. Use visually appealing designs: thoughtful colors, smooth animations, readable typography
+
+## Naming rules (critical)
+- Any exported component name MUST be a valid TypeScript/JavaScript identifier in PascalCase.
+- The name MUST start with a letter A-Z (NEVER start with a digit or any non-ASCII character).
+- Use ASCII letters and digits only for identifiers (A-Z, a-z, 0-9). Do NOT use Japanese text, Chinese text, spaces, hyphens, underscores at the start, emoji, or punctuation in identifiers.
+- If the title is non-Latin (e.g. Japanese or Chinese), you MUST romanize/translate it for the identifier.
+ - BAD: "20秒でわかる三権分立" → identifier starts with digit AND contains Japanese → INVALID
+ - GOOD: "20秒でわかる三権分立" → "SankenbunritsuIn20Seconds" (romanized + digit moved inside) → VALID
+ - BAD: "量子コンピュータ入門" → contains Japanese → INVALID
+ - GOOD: "量子コンピュータ入門" → "IntroToQuantumComputers" → VALID
+- This rule applies to EVERY identifier: componentName in scenes AND any name derived from videoTitle.
+
+## Output completeness rules (CRITICAL)
+- You MUST output the **entire file** from the first \`import\` to the final closing \`};\` — never truncate.
+- Every opened bracket \`{\`, parenthesis \`(\`, and square bracket \`[\` MUST have a matching close.
+- Every JSX tag that is opened MUST be closed (self-closing or with a closing tag).
+- Every string literal started with \`"\`, \`'\`, or a template literal \`\`\` MUST be terminated on the same line or properly closed.
+- The last line of the file MUST be \`};\` (the closing of the exported component arrow function) followed by a newline.
+- If the component logic is becoming long, **simplify the design** — reduce the number of animated elements, use fewer style properties, or combine logic. Do NOT emit a partial file.
+- Never rely on the reader to "complete" your output. The file must be runnable exactly as emitted.
+
+## Minimal valid example
+\`\`\`tsx
+import React from 'react';
+import { useCurrentFrame, useVideoConfig, interpolate, spring } from '@open-motion/core';
+
+export const ExampleScene = () => {
+ const frame = useCurrentFrame();
+ const { width, height, fps } = useVideoConfig();
+
+ const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
+ const scale = spring({ frame, fps, config: { stiffness: 80, damping: 12 } });
+
+ return (
+
+
+ Hello open-motion
+
+
+ );
+};
+\`\`\`
+`;
+
+// ---------------------------------------------------------------------------
+// Caption (SRT) generation
+// ---------------------------------------------------------------------------
+
+export const CAPTIONS_SYSTEM_PROMPT = `You generate subtitle captions for videos.
+
+You must output VALID JSON only (no markdown, no extra keys).
+The JSON must contain a single key: { "srt": "..." } where the value is a complete SRT file contents.
+
+SRT rules:
+- Use standard time format: HH:MM:SS,mmm --> HH:MM:SS,mmm
+- Number captions starting from 1
+- Separate each caption block with a blank line
+- Do not include backticks in the SRT text
+- Keep each caption to 1-2 lines
+- Ensure times are strictly increasing and do not overlap
+`;
+
+export interface CaptionsContext {
+ videoTitle: string;
+ description: string;
+ scenes: Array<{
+ title: string;
+ description: string;
+ startInSeconds: number;
+ endInSeconds: number;
+ }>;
+}
+
+export function buildCaptionsPrompt(ctx: CaptionsContext): string {
+ return `Create subtitles (SRT) for the following video.
+
+Video title: ${ctx.videoTitle}
+Video description: ${ctx.description}
+
+Scene timeline (must keep captions within these bounds):
+${ctx.scenes
+ .map(
+ (s, i) =>
+ `${i + 1}. ${s.title} (${s.startInSeconds.toFixed(3)}s - ${s.endInSeconds.toFixed(3)}s): ${s.description}`
+ )
+ .join('\n')}
+
+Guidelines:
+- Write captions that match what the viewer sees on-screen in each scene (short, punchy)
+- Prefer 1-3 caption blocks per scene
+- Keep the total captions aligned to the total timeline (from 0s to ${ctx.scenes[ctx.scenes.length - 1].endInSeconds.toFixed(
+ 3
+ )}s)
+
+Respond with JSON only in this exact shape:
+{ "srt": "" }`;
+}
+
+export function parseCaptionsResponse(text: string): { srt: string } {
+ const cleaned = text
+ .replace(/^```(?:json)?\s*/m, '')
+ .replace(/\s*```\s*$/m, '')
+ .trim();
+
+ let out: { srt: string };
+ try {
+ out = JSON.parse(cleaned);
+ } catch {
+ throw new Error(`LLM returned invalid JSON for captions.\nRaw response:\n${text}`);
+ }
+
+ if (!out || typeof out.srt !== 'string' || out.srt.trim().length === 0) {
+ throw new Error(`LLM captions response is missing required field "srt".\nParsed:\n${JSON.stringify(out, null, 2)}`);
+ }
+
+ return { srt: out.srt };
+}
+
+// ---------------------------------------------------------------------------
+// Planning prompt: ask the LLM to break a description into scenes
+// ---------------------------------------------------------------------------
+export function buildPlanningPrompt(description: string): string {
+ return `Analyze the following video description and break it into logical scenes.
+
+Video description: "${description}"
+
+Respond with a JSON object (and nothing else) in this exact shape:
+{
+ "videoTitle": "A concise title for the entire video (may contain any language/characters)",
+ "scenes": [
+ {
+ "id": "kebab-case-unique-id",
+ "componentName": "PascalCaseComponentName",
+ "title": "Short scene title",
+ "description": "What this scene shows and how it animates",
+ "durationInSeconds": 5
+ }
+ ]
+}
+
+Guidelines:
+- Total video duration should feel natural for the content (typically 20-90 seconds)
+- Each scene should be self-contained and visually distinct
+- \`videoTitle\` is a human-readable display title and may use any language or characters.
+- \`componentName\` must be a valid PascalCase React component name AND a valid JS/TS identifier:
+ - start with A-Z (NEVER start with a digit or non-ASCII character)
+ - ASCII letters/digits only (no Japanese/Chinese/non-ASCII characters, no spaces, no hyphens)
+ - If the scene title is Japanese/Chinese, romanize it: e.g. "三権分立とは" → "WhatIsSankenbunritsu"
+ - examples: "IntroScene", "HowItWorksScene", "SankenbunritsuIn20SecondsScene"
+- \`id\` must be unique kebab-case (e.g. "intro-scene", "data-flow-scene")
+- Aim for 3-6 scenes unless the content clearly needs more or fewer
+- Describe animations concretely (e.g. "text fades in from bottom, then a line draws across")
+
+Return ONLY the JSON object. No markdown fences, no explanation.`;
+}
+
+// ---------------------------------------------------------------------------
+// Code generation prompt: ask the LLM to write TSX for one scene
+// ---------------------------------------------------------------------------
+export interface SceneCodeContext {
+ componentName: string;
+ title: string;
+ description: string;
+ durationInSeconds: number;
+ durationInFrames: number;
+ fps: number;
+ width: number;
+ height: number;
+ sceneIndex: number;
+ totalScenes: number;
+ /** Populated on retry attempts with a description of the previous failure. */
+ retryContext?: string;
+}
+
+export function buildSceneCodePrompt(ctx: SceneCodeContext): string {
+ const retrySection = ctx.retryContext
+ ? `\n## Previous attempt failed — read carefully before writing\n${ctx.retryContext}\n`
+ : '';
+
+ return `${retrySection}Generate a complete TSX file for the following open-motion scene:
+
+Component name : ${ctx.componentName}
+Scene title : ${ctx.title}
+Scene index : ${ctx.sceneIndex + 1} of ${ctx.totalScenes}
+Duration : ${ctx.durationInSeconds}s = ${ctx.durationInFrames} frames at ${ctx.fps}fps
+Video size : ${ctx.width}x${ctx.height}
+
+Scene description:
+${ctx.description}
+
+Requirements:
+- The component must be named exactly \`${ctx.componentName}\` and exported as a named export
+- The component name must be a valid JS/TS identifier (ASCII PascalCase, must not start with a digit)
+- Fill the full ${ctx.width}x${ctx.height} canvas
+- Use smooth frame-based animations (interpolate / spring)
+- Keep text readable and well-positioned
+- Use colors and typography appropriate for the scene's content
+- Do NOT include any Composition registration
+
+Completeness rules (CRITICAL — violations cause build errors):
+- Output the ENTIRE file: from the first \`import\` line to the final \`};\` closing line.
+- Every \`{\` must have a matching \`}\`, every \`(\` a \`)\`, every \`[\` a \`]\`.
+- Every JSX attribute string value started with \`"\` must be closed with \`"\` on the same line.
+- Every JSX element opened must be closed (self-closing \`/>\` or with a \`\`).
+- Every template literal \`\`\`\` must be terminated with a closing \`\`\`\`\`.
+- The very last line must be \`};\` (end of the exported component), then a newline.
+- If the design is growing too complex, simplify it — use fewer animated elements, fewer style properties. NEVER emit a partial or truncated file.
+
+Return ONLY the raw TSX file content. No markdown code fences, no explanation.`;
+}
+
+// ---------------------------------------------------------------------------
+// Response parser: extract JSON plan from LLM response
+// ---------------------------------------------------------------------------
+export interface ScenePlan {
+ videoTitle: string;
+ scenes: Array<{
+ id: string;
+ componentName: string;
+ title: string;
+ description: string;
+ durationInSeconds: number;
+ }>;
+}
+
+export function parsePlanResponse(text: string): ScenePlan {
+ // Strip markdown code fences if the model included them anyway
+ const cleaned = text
+ .replace(/^```(?:json)?\s*/m, '')
+ .replace(/\s*```\s*$/m, '')
+ .trim();
+
+ let plan: ScenePlan;
+ try {
+ plan = JSON.parse(cleaned);
+ } catch {
+ throw new Error(
+ `LLM returned invalid JSON for scene plan.\nRaw response:\n${text}`
+ );
+ }
+
+ if (!plan.videoTitle || !Array.isArray(plan.scenes) || plan.scenes.length === 0) {
+ throw new Error(
+ `LLM scene plan is missing required fields.\nParsed:\n${JSON.stringify(plan, null, 2)}`
+ );
+ }
+
+ // Validate that every componentName is a legal JS/TS identifier.
+ // This is a defence-in-depth check: the prompt already instructs the LLM,
+ // but we must not trust LLM output blindly.
+ const validIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
+ for (const scene of plan.scenes) {
+ if (!scene.componentName || !validIdentifier.test(scene.componentName)) {
+ throw new Error(
+ `LLM returned an invalid componentName: "${scene.componentName}".\n` +
+ `componentName must be a valid JS/TS identifier (ASCII PascalCase, must not start with a digit).\n` +
+ `If the title is non-Latin (e.g. Japanese), the LLM must romanize it.\n` +
+ `Please retry the generation.`
+ );
+ }
+ }
+
+ return plan;
+}
+
+/**
+ * Strip markdown code fences from a raw TSX response, if present.
+ */
+export function parseCodeResponse(text: string): string {
+ return text
+ .replace(/^```(?:tsx?|jsx?|typescript|javascript)?\s*\n?/m, '')
+ .replace(/\n?```\s*$/m, '')
+ .trim();
+}
+
+/**
+ * Validate that generated scene code contains the expected named export.
+ * Throws a descriptive error if the code is empty or the export is missing.
+ *
+ * Also runs a set of lightweight syntactic integrity checks that catch the
+ * most common truncation / partial-output failures:
+ * 1. Named export presence
+ * 2. Bracket / parenthesis / square-bracket balance (outside string literals)
+ * 3. JSX attribute string value closure on the same line
+ * 4. File ends with a closing `}` or `};`
+ */
+export function validateSceneCode(code: string, componentName: string): void {
+ if (!code || code.trim().length === 0) {
+ throw new Error(
+ `LLM returned empty code for component "${componentName}". The scene file would be blank.`
+ );
+ }
+
+ // ── 1. Named export check ──────────────────────────────────────────────────
+ // Accept: export const Foo, export function Foo, export class Foo
+ const namedExportRe = new RegExp(
+ `export\\s+(const|function|class)\\s+${componentName}\\b`
+ );
+ if (!namedExportRe.test(code)) {
+ throw new Error(
+ `Generated code for "${componentName}" is missing the required named export.\n` +
+ `Expected: \`export const ${componentName} = ...\` (or export function/class).\n` +
+ `The exported name in the file must exactly match "${componentName}".`
+ );
+ }
+
+ // ── 2. Bracket balance check (skip contents of string/template literals) ───
+ //
+ // Strategy: walk character-by-character, tracking whether we are inside a
+ // string literal ('…', "…") or template literal (`…`). Inside a literal we
+ // ignore brackets entirely. This is not a full parser but catches the most
+ // common truncation patterns (e.g. an object literal whose value was cut off).
+ //
+ // Template literal nesting is handled with a stack:
+ // - Entering `` ` `` pushes 'template' onto the context stack.
+ // - Inside a template, `${` begins an expression: we count the opening `{`
+ // in curly and push 'expr' so we know when the matching `}` exits back
+ // into the template (rather than decrementing curly a second time).
+ // - Inside 'expr' context, all normal bracket counting applies, and a
+ // nested `` ` `` pushes another 'template' level.
+ {
+ let inSingle = false; // inside '…'
+ let inDouble = false; // inside "…"
+ // Stack entries: 'template' | 'expr'
+ // 'template' = we are inside `…` (template literal body)
+ // 'expr' = we are inside ${…} inside a template literal
+ const templateStack: Array<'template' | 'expr'> = [];
+ let curly = 0;
+ let paren = 0;
+ let square = 0;
+
+ const inTemplateBody = () =>
+ templateStack.length > 0 && templateStack[templateStack.length - 1] === 'template';
+ const inString = () => inSingle || inDouble || inTemplateBody();
+
+ for (let ci = 0; ci < code.length; ci++) {
+ const ch = code[ci];
+
+ // Handle escape sequences inside string/template literals
+ if (inString() && ch === '\\') {
+ ci++; // skip the escaped character
+ continue;
+ }
+
+ if (!inString()) {
+ if (ch === "'") { inSingle = true; }
+ else if (ch === '"') { inDouble = true; }
+ else if (ch === '`') { templateStack.push('template'); }
+ else if (ch === '{') { curly++; }
+ else if (ch === '}') {
+ // If we are inside a template expression (${…}), this `}` closes
+ // the expression and returns us to the template literal body.
+ if (templateStack.length > 0 && templateStack[templateStack.length - 1] === 'expr') {
+ templateStack.pop();
+ curly--; // the matching `{` from `${` was already counted
+ } else {
+ curly--;
+ }
+ }
+ else if (ch === '(') { paren++; }
+ else if (ch === ')') { paren--; }
+ else if (ch === '[') { square++; }
+ else if (ch === ']') { square--; }
+ } else {
+ // Inside a string — check for closing delimiter
+ if (inSingle && ch === "'") { inSingle = false; }
+ else if (inDouble && ch === '"') { inDouble = false; }
+ else if (inTemplateBody()) {
+ if (ch === '`') {
+ templateStack.pop(); // close this template literal level
+ } else if (ch === '$' && code[ci + 1] === '{') {
+ // Start of a template expression — count the `{` and enter expr context
+ ci++; // skip the `{`
+ curly++;
+ templateStack.push('expr');
+ }
+ }
+ }
+ }
+
+ const imbalanced: string[] = [];
+ if (curly !== 0) imbalanced.push(`curly braces {} (net ${curly > 0 ? '+' : ''}${curly})`);
+ if (paren !== 0) imbalanced.push(`parentheses () (net ${paren > 0 ? '+' : ''}${paren})`);
+ if (square !== 0) imbalanced.push(`square brackets [] (net ${square > 0 ? '+' : ''}${square})`);
+ if (inSingle) imbalanced.push(`unterminated single-quoted string`);
+ if (inDouble) imbalanced.push(`unterminated double-quoted string`);
+ if (templateStack.length > 0) imbalanced.push(`unterminated template literal`);
+
+ if (imbalanced.length > 0) {
+ throw new Error(
+ `Generated code for "${componentName}" appears to be incomplete or truncated.\n` +
+ `Syntax issues detected:\n` +
+ imbalanced.map(s => ` • ${s}`).join('\n') + '\n' +
+ `The file was likely cut off mid-way. The LLM should produce a shorter, simpler component.`
+ );
+ }
+ }
+
+ // ── 3. File-end check ─────────────────────────────────────────────────────
+ // The last non-empty line should close the exported component: `}` or `};`
+ {
+ const lines = code.split('\n');
+ const lastNonEmpty = [...lines].reverse().find(l => l.trim().length > 0) ?? '';
+ const trimmed = lastNonEmpty.trim();
+ if (trimmed !== '}' && trimmed !== '};') {
+ throw new Error(
+ `Generated code for "${componentName}" does not end with \`};\` or \`}\`.\n` +
+ `Last non-empty line is: ${JSON.stringify(lastNonEmpty)}\n` +
+ `The file was likely truncated before the component was fully closed.`
+ );
+ }
+ }
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 067deb7..498063b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -2,7 +2,15 @@
"name": "@open-motion/core",
"version": "0.1.9",
"main": "dist/index.js",
+ "module": "dist/index.js",
"types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "require": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
"license": "MIT",
"scripts": {
"build": "tsc"
diff --git a/packages/core/src/Audio.tsx b/packages/core/src/Audio.tsx
new file mode 100644
index 0000000..d509670
--- /dev/null
+++ b/packages/core/src/Audio.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+export interface AudioProps {
+ src: string;
+ startFrom?: number;
+ startFrame?: number;
+ volume?: number;
+}
+
+export const Audio: React.FC = (props) => {
+ const startFrame = props.startFrame ?? 0;
+
+ if (typeof window !== 'undefined') {
+ (window as any).__OPEN_MOTION_AUDIO_ASSETS__ = (window as any).__OPEN_MOTION_AUDIO_ASSETS__ || [];
+ const exists = (window as any).__OPEN_MOTION_AUDIO_ASSETS__.find(
+ (a: any) =>
+ a.src === props.src &&
+ (a.startFrom || 0) === (props.startFrom || 0) &&
+ (a.volume || 1) === (props.volume || 1) &&
+ a.startFrame === startFrame
+ );
+ if (!exists) {
+ console.log('[Audio] Registering asset:', props.src, 'startFrame:', startFrame, 'volume:', props.volume);
+ (window as any).__OPEN_MOTION_AUDIO_ASSETS__.push({
+ ...props,
+ startFrame,
+ });
+ }
+ }
+ return null;
+};
diff --git a/packages/core/src/AudioSync.tsx b/packages/core/src/AudioSync.tsx
new file mode 100644
index 0000000..a6b8ff7
--- /dev/null
+++ b/packages/core/src/AudioSync.tsx
@@ -0,0 +1,139 @@
+import React, { useRef, useEffect } from 'react';
+
+export interface AudioOptions {
+ startFrom?: number;
+ startFrame?: number;
+ volume?: number;
+}
+
+export interface AudioSyncManagerProps {
+ frame: number;
+ fps: number;
+ isPlaying: boolean;
+ durationInFrames: number;
+}
+
+interface AudioEntry {
+ audio: HTMLAudioElement;
+ loaded: boolean;
+ pendingPlay: boolean;
+}
+
+export const AudioSyncManager: React.FC = ({
+ frame,
+ fps,
+ isPlaying,
+}) => {
+ const audioElementsRef = useRef