Remotion Video
用 React 以编程方式创建 MP4 视频的框架。
📚 详细规则文件索引
根据具体需求,加载对应的规则文件获取详细代码示例:
基础动画
- •rules/animations.md - 动画基础:必须使用
useCurrentFrame()驱动 - •rules/timing.md - 时间插值:interpolate、spring、easing 曲线
- •rules/sequencing.md - 时间编排:延迟、裁剪、限制时长
- •rules/trimming.md - 裁剪动画的开头或结尾
- •rules/transitions.md - 场景过渡效果
媒体资源
- •rules/assets.md - 导入图片、视频、音频、字体
- •rules/images.md - 使用
<Img>组件嵌入图片 - •rules/videos.md - 嵌入视频:裁剪、音量、速度、循环、音高
- •rules/audio.md - 音频处理:导入、裁剪、音量、速度、音高
- •rules/fonts.md - 加载 Google Fonts 和本地字体
- •rules/gifs.md - 与时间轴同步的 GIF 显示
字幕与文字
- •rules/subtitles.md - 字幕和 Caption 概览
- •rules/transcribe-captions.md - 转录音视频生成字幕
- •rules/display-captions.md - 显示字幕
- •rules/import-srt-captions.md - 导入 SRT 字幕文件
- •rules/text-animations.md - 文字排版和动画
- •rules/measuring-text.md - 测量文字尺寸、适配容器
视觉效果
- •rules/charts.md - 图表和数据可视化
- •rules/lottie.md - 嵌入 Lottie 动画
- •rules/light-leaks.md - 光晕叠加效果
- •rules/transparent-videos.md - 渲染透明背景视频
- •rules/maps.md - 使用 Mapbox 添加地图并动画化
3D 内容
- •rules/3d.md - 使用 Three.js 和 React Three Fiber 创建 3D 内容
Composition 配置
- •rules/compositions.md - 定义 Composition、静态图、默认 props
- •rules/calculate-metadata.md - 动态设置时长、尺寸、props
- •rules/parameters.md - 使用 Zod Schema 参数化视频
媒体信息工具 (Mediabunny)
- •rules/get-audio-duration.md - 获取音频时长
- •rules/get-video-duration.md - 获取视频时长
- •rules/get-video-dimensions.md - 获取视频尺寸
- •rules/can-decode.md - 检查视频是否可解码
- •rules/extract-frames.md - 从视频提取帧
其他
- •rules/tailwind.md - 在 Remotion 中使用 TailwindCSS
- •rules/measuring-dom-nodes.md - 测量 DOM 元素尺寸
🎬 视频创作工作流(重要)
工作流程概览
1. 需求收集 → 2. 文案创作(2-3方案)→ 3. 用户选择 → 4. 素材清单 → 5. 语音生成 → 6. 视频开发 → 7. 渲染
第一步:文案设计(最重要)
核心原则:每个场景必须有「声」有「画」
视频不是 PPT + 配音!每个场景需要同时设计:
- •语音脚本:讲什么
- •视觉元素:看什么(文字/图片/动画/视频片段)
- •镜头感:画面如何变化
场景设计模板
场景 1: 开场
语音: "你有没有遇到过这样的情况..."
画面:
- 0-2秒: 黑屏 + 大字标题淡入
- 2-5秒: 问题场景的图片/视频
- 5-8秒: 主角人物或产品特写
动画: 标题弹入 + 图片缩放
素材需求: [问题场景图片, 主角图片]
场景 2: 问题展示
语音: "手机突然开始疯狂闪退..."
画面:
- 手机屏幕录屏展示问题
- 或: 模拟的错误提示动画
动画: 红色警告框抖动
素材需求: [屏幕录屏.mp4] 或 [自制动画]
⚠️ 素材确认检查点
在开始开发前,必须向用户确认:
- •每个场景的素材是否齐全?
- •缺失的素材用户能否提供?
- •是否需要用动画/图表代替实拍素材?
- •用户对视觉风格有什么偏好?
提问模板: "我设计了以下场景,但有几处需要确认: 1. 场景3需要一张产品截图,您能提供吗? 2. 场景5的演示部分,您有屏幕录制吗?还是我用动画模拟? 3. 整体风格偏科技感(深色背景)还是温馨感(浅色背景)?"
第二步:提供2-3个文案方案
在用户确认最终文案前,必须先提供多个方案供选择。
每个方案应该有不同的侧重:
| 方案 | 风格 | 节奏 | 时长 | 适合场景 |
|---|---|---|---|---|
| A | 快节奏 | 信息密集,画面切换快 | 1-2分钟 | 短视频平台、产品功能展示 |
| B | 故事型 | 起承转合,情感递进 | 3-5分钟 | 个人故事、品牌宣传 |
| C | 教程型 | 分步讲解,留白思考 | 5-10分钟 | 技术教程、知识科普 |
方案对比模板
## 方案 A:「快节奏产品展示」(预计 90 秒) - 开场(10s): 痛点问题一句话带过 - 产品展示(60s): 功能快速切换,每个功能 10 秒 - 结尾(20s): CTA 引导 ## 方案 B:「问题解决故事」(预计 3 分钟) - 开场(30s): 详细描述遇到的问题,引发共鸣 - 探索(60s): 尝试的各种方案,一一失败 - 转折(30s): 发现关键线索 - 解决(45s): 最终方案和效果 - 彩蛋(15s): 意外收获或思考 ## 方案 C:「技术深度讲解」(预计 5 分钟) - 背景介绍(45s) - 概念解释 1(60s) - 概念解释 2(60s) - 实操演示(90s) - 总结回顾(45s) --- 请选择您倾向的方案,或者告诉我您想要的调整方向。
第三步:确认素材清单
在用户选定方案后,输出素材清单:
## 素材清单 ### 必须提供(用户准备) - [ ] 产品截图 (1920x1080) - 用于场景2 - [ ] 屏幕录制.mp4 - 用于场景4演示 - [ ] Logo.png (透明背景) - 片头片尾 ### 可选提供(有则更好) - [ ] 背景音乐.mp3 - [ ] 真人出镜视频 ### 我来制作(动画/图表) - 时间线动画 - 场景3 - 数据对比图表 - 场景5 - 文字动效 - 全片 请确认以上素材,我会在收到后开始开发。
🔧 环境配置
系统依赖
# macOS brew install node ffmpeg # Ubuntu/Debian sudo apt install nodejs ffmpeg
TTS 依赖(Edge TTS 免费版需要)
# 在项目目录下安装 npm install msedge-tts
Chrome Headless Shell(渲染用)
Remotion 渲染需要 Chrome Headless Shell,首次会自动下载。可通过环境变量配置:
# 使用系统 Chrome(推荐,避免重复下载) export PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
package.json 推荐配置
{
"scripts": {
"dev": "remotion studio",
"audio": "npx ts-node scripts/generate-audio.ts",
"audio:edge": "npx ts-node scripts/generate-audio.ts -p edge",
"render": "npx remotion render MainVideo out/video.mp4",
"check-env": "bash scripts/check-env.sh"
}
}
核心概念
- •Composition - 视频的定义(尺寸、帧率、时长)
- •useCurrentFrame() - 获取当前帧号,驱动动画
- •interpolate() - 将帧号映射到任意值(位置、透明度等)
- •spring() - 物理动画效果
- •<Sequence> - 时间轴上排列组件
快速开始
创建新项目
npx create-video@latest
选择模板后:
cd <project-name> npm run dev # 启动 Remotion Studio 预览
项目结构
my-video/ ├── src/ │ ├── Root.tsx # 注册所有 Composition │ ├── HelloWorld.tsx # 视频组件 │ └── index.ts # 入口 ├── public/ # 静态资源(音频、图片) ├── remotion.config.ts # 配置文件 └── package.json
基础组件示例
最小视频组件
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
export const MyVideo = () => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "white", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ fontSize: 100 }}>Frame {frame}</h1>
</AbsoluteFill>
);
};
注册 Composition
// Root.tsx
import { Composition } from "remotion";
import { MyVideo } from "./MyVideo";
export const RemotionRoot = () => {
return (
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={150} // 5秒 @ 30fps
fps={30}
width={1920}
height={1080}
/>
);
};
动画技巧
interpolate - 值映射
import { interpolate, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// 0-30帧:透明度 0→1
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: "clamp", // 超出范围时钳制
});
// 位移动画
const translateY = interpolate(frame, [0, 30], [50, 0]);
spring - 物理动画
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
config: { damping: 10, stiffness: 100 },
});
Sequence - 时间编排
import { Sequence } from "remotion";
<>
<Sequence from={0} durationInFrames={60}>
<Intro />
</Sequence>
<Sequence from={60} durationInFrames={90}>
<MainContent />
</Sequence>
<Sequence from={150}>
<Outro />
</Sequence>
</>
AI 语音解说集成
为视频添加 AI 语音解说,实现音视频同步。支持三种方案:
TTS 方案选择指南
用户需要语音配音
│
▼
┌─────────────────────────────────────┐
│ 询问用户: │
│ 1. 是否需要克隆特定音色? │
│ 2. 是否有 API Key? │
│ 3. 预算情况? │
└─────────────────────────────────────┘
│
├── 需要克隆音色 ──────────────▶ MiniMax TTS(上传音频克隆)
│
├── 有阿里云账号 + 中文 ───────▶ Qwen TTS(音质优秀,价格便宜)
│
├── 无预算 / 快速测试 ─────────▶ Edge TTS(免费,固定音色)
│
└── 需要多语言 / 特殊需求 ─────▶ 询问用户偏好
方案对比表
| 方案 | 优点 | 缺点 | 价格 | 推荐场景 |
|---|---|---|---|---|
| MiniMax TTS | 音色克隆、速度快 | 按字符计费 | ¥0.05-0.1/千字 | 需要特定音色 |
| Qwen TTS | 音质优秀、多种预置音色 | 需阿里云账号 | ¥0.02-0.04/千字 | 中文视频首选 |
| Edge TTS | 免费、无需配置 | 固定音色 | 免费 | 快速测试 |
首次配置
# Edge TTS 需要安装 npm 包(免费) npm install msedge-tts # 付费方案只需设置环境变量 export MINIMAX_API_KEY="..." export MINIMAX_VOICE_ID="..." # 或 export DASHSCOPE_API_KEY="..."
使用流程
# 自动选择可用的 TTS 提供商 npx ts-node scripts/generate-audio.ts # 指定提供商 npx ts-node scripts/generate-audio.ts --provider edge # 免费 npx ts-node scripts/generate-audio.ts --provider qwen # 需要 API Key npx ts-node scripts/generate-audio.ts --provider minimax # 需要 API Key # 设置重试次数(默认 3 次) npx ts-node scripts/generate-audio.ts --retries 5
TTS 提供商详情
MiniMax TTS(推荐,支持音色克隆)
- •注册 https://www.minimax.io(国际版)或 https://platform.minimaxi.com(国内版)
- •获取 API Key 和 Voice ID
- •设置环境变量:
export MINIMAX_API_KEY="your_api_key" export MINIMAX_VOICE_ID="your_voice_id"
API 域名:国际版 api.minimax.io,国内版 api.minimaxi.com
价格:¥0.05-0.1/千字符
Qwen TTS (CosyVoice)
- •注册阿里云 DashScope:https://dashscope.console.aliyun.com/
- •获取 API Key
- •设置环境变量:
export DASHSCOPE_API_KEY="your_api_key" export QWEN_VOICE="longfei" # 可选
可用音色:
| 音色 ID | 名称 | 风格 |
|---|---|---|
| longfei | 龙飞 | 专业男声(推荐) |
| longshu | 龙叔 | 成熟男声 |
| longwan | 龙婉 | 知性女声 |
| longxiaochun | 龙小淳 | 温柔女声 |
价格:¥0.02-0.04/千字符
Edge TTS(免费)
无需 API Key,需要安装 npm 包:
npm install msedge-tts
可用音色:
| 语音 ID | 名称 | 风格 |
|---|---|---|
| zh-CN-YunyangNeural | 云扬 | 专业播音腔(推荐) |
| zh-CN-XiaoxiaoNeural | 晓晓 | 温暖自然 |
| zh-CN-YunxiNeural | 云希 | 阳光少年 |
设置音色:export EDGE_VOICE="zh-CN-YunyangNeural"
Remotion 音频同步
import { Audio, Sequence, staticFile } from "remotion";
// 音频配置(根据生成的时长)
const audioConfig = [
{ id: "01-intro", file: "01-intro.mp3", frames: 450 },
{ id: "02-main", file: "02-main.mp3", frames: 600 },
];
// 计算起始帧
const sceneStarts = audioConfig.reduce((acc, _, i) => {
if (i === 0) return [0];
return [...acc, acc[i - 1] + audioConfig[i - 1].frames];
}, [] as number[]);
// 场景渲染
{audioConfig.map((scene, i) => (
<Sequence key={scene.id} from={sceneStarts[i]} durationInFrames={scene.frames}>
<SceneComponent />
<Audio src={staticFile(scene.file)} />
</Sequence>
))}
教程类视频架构(场景驱动)
教程、讲解类视频的核心架构:音频驱动场景切换。
架构概览
音频脚本 → TTS 生成 → audioConfig.ts → 场景组件 → 视频渲染
关键思想:
- •音频决定时长:每个场景的持续时间由音频长度决定
- •场景即章节:一个概念 = 一个场景 = 一段音频
- •配置即真理:
audioConfig.ts是音画同步的单一数据源
audioConfig.ts 模板
参见 templates/audioConfig.ts,包含:
- •SceneConfig 接口定义
- •SCENES 数组
- •getSceneStart() 计算函数
- •TOTAL_FRAMES 和 FPS 常量
场景切换 Hook
import { useCurrentFrame } from "remotion";
import { SCENES } from "./audioConfig";
// 根据当前帧号返回场景索引
const useCurrentSceneIndex = () => {
const frame = useCurrentFrame();
let accumulated = 0;
for (let i = 0; i < SCENES.length; i++) {
accumulated += SCENES[i].durationInFrames;
if (frame < accumulated) return i;
}
return SCENES.length - 1;
};
// 使用
const sceneIndex = useCurrentSceneIndex();
const currentScene = SCENES[sceneIndex];
主场景组件模式
import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remotion";
import { ThreeCanvas } from "@remotion/three";
import { SCENES, getSceneStart, TOTAL_FRAMES } from "./audioConfig";
export const TutorialVideo: React.FC = () => {
const { width, height } = useVideoConfig();
const sceneIndex = useCurrentSceneIndex();
const currentScene = SCENES[sceneIndex];
return (
<AbsoluteFill style={{ backgroundColor: "#1a1a2e" }}>
{/* 3D 内容 */}
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0, 4], fov: 50 }}>
{/* 根据 sceneIndex 渲染不同场景 */}
{sceneIndex === 0 && <Scene01Intro />}
{sceneIndex === 1 && <Scene02Concept />}
{sceneIndex === 2 && <Scene03Demo />}
</ThreeCanvas>
{/* 音频同步 - 每个场景一个 Sequence */}
{SCENES.map((scene, idx) => (
<Sequence key={scene.id} from={getSceneStart(idx)} durationInFrames={scene.durationInFrames}>
<Audio src={staticFile(`audio/${scene.audioFile}`)} />
</Sequence>
))}
{/* UI 层:标题 + 进度 */}
<div style={{ position: "absolute", top: 40, left: 0, right: 0, textAlign: "center" }}>
<h1 style={{ color: "white", fontSize: 42 }}>教程标题</h1>
</div>
<div style={{ position: "absolute", bottom: 60, left: 60 }}>
<span style={{ color: "white" }}>{currentScene?.title}</span>
</div>
{/* 进度条 */}
<div style={{ position: "absolute", bottom: 30, left: 60, right: 60, height: 4, backgroundColor: "rgba(255,255,255,0.2)" }}>
<div style={{ width: `${((sceneIndex + 1) / SCENES.length) * 100}%`, height: "100%", backgroundColor: "#3498DB" }} />
</div>
</AbsoluteFill>
);
};
Root.tsx 使用动态帧数
import { Composition } from "remotion";
import { TutorialVideo } from "./TutorialVideo";
import { TOTAL_FRAMES } from "./audioConfig";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="Tutorial"
component={TutorialVideo}
fps={30}
durationInFrames={TOTAL_FRAMES} // 从 audioConfig 动态获取
width={1920}
height={1080}
/>
);
};
⚠️ 教程视频踩坑经验
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 场景切换生硬 | 直接切换无过渡 | 用 spring/interpolate 添加入场动画 |
| 3D 内容与音频不同步 | 硬编码帧数 | 所有时长从 audioConfig 读取 |
| 渲染时 WebGL 崩溃 | 多个 ThreeCanvas 同时存在 | 用 sceneIndex 条件渲染,同时只有一个 3D 场景 |
| 视频太简略 | 只有一个大场景 | 一个概念 = 一个场景组件,分层讲解 |
场景组件设计原则
- •单一职责:每个场景组件只负责一个概念
- •独立动画:每个场景有自己的 useCurrentFrame(),动画从 0 开始
- •延迟出现:用 delay 参数控制元素依次出现
- •相机适配:不同场景可能需要不同相机位置
// 场景组件示例
const Scene02Input: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 入场动画
const gridScale = spring({ frame, fps, config: { damping: 15 } });
return (
<group>
<PixelGrid position={[0, 0, 0]} scale={gridScale * 1.5} />
</group>
);
};
相机控制器模式
import { useThree } from "@react-three/fiber";
// ✅ 推荐写法:直接设置相机位置,避免插值导致的持续抖动
const CameraController: React.FC<{ sceneIndex: number }> = ({ sceneIndex }) => {
const { camera } = useThree();
const cameraSettings: Record<number, [number, number, number]> = {
0: [0, 0, 4], // 开场:正面
1: [0, 0, 3], // 输入层:靠近
2: [-0.5, 0, 3.5], // 卷积:偏左
3: [0, 0, 5], // 总结:拉远全景
};
const target = cameraSettings[sceneIndex] || [0, 0, 4];
// 直接设置位置,不用插值
camera.position.set(target[0], target[1], target[2]);
camera.lookAt(0, 0, 0);
return null;
};
⚠️ 不要用 position += (target - position) * factor 这种写法,永远无法精确收敛,会导致画面持续抖动。详见「🚨 3D 场景常见陷阱 - 陷阱1」。
常用功能
添加视频/音频
import { Video, Audio, staticFile } from "remotion";
// 使用 public/ 目录下的文件
<Video src={staticFile("background.mp4")} />
<Audio src={staticFile("music.mp3")} volume={0.5} />
// 外部 URL
<Video src="https://example.com/video.mp4" />
添加图片
import { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} style={{ width: 200 }} />
参数化视频(动态数据)
// 定义 props schema
const myCompSchema = z.object({
title: z.string(),
bgColor: z.string(),
});
export const MyVideo: React.FC<z.infer<typeof myCompSchema>> = ({ title, bgColor }) => {
return (
<AbsoluteFill style={{ backgroundColor: bgColor }}>
<h1>{title}</h1>
</AbsoluteFill>
);
};
// 注册时传入默认值
<Composition
id="MyVideo"
component={MyVideo}
schema={myCompSchema}
defaultProps={{ title: "Hello", bgColor: "#ffffff" }}
...
/>
渲染输出
CLI 渲染
# 渲染为 MP4 npx remotion render MyVideo out/video.mp4 # 指定编码器 npx remotion render --codec=h264 MyVideo out/video.mp4 # WebM 格式 npx remotion render --codec=vp8 MyVideo out/video.webm # GIF npx remotion render --codec=gif MyVideo out/video.gif # 仅音频 npx remotion render --codec=mp3 MyVideo out/audio.mp3 # 图片序列 npx remotion render --sequence MyVideo out/frames # 单帧静态图 npx remotion still MyVideo --frame=30 out/thumbnail.png
常用渲染参数
| 参数 | 说明 |
|---|---|
--codec | h264, h265, vp8, vp9, gif, mp3, wav 等 |
--crf | 质量 (0-51,越小越好,默认18) |
--props | JSON 格式传入 props |
--scale | 缩放因子 |
--concurrency | 并行渲染数 |
高级功能
字幕 (@remotion/captions)
npm i @remotion/captions @remotion/install-whisper-cpp npx remotion-install-whisper-cpp # 安装 Whisper
import { transcribe } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "audio.mp3",
whisperPath: whisperCppPath,
model: "medium",
});
播放器嵌入 Web 应用
npm i @remotion/player
import { Player } from "@remotion/player";
import { MyVideo } from "./MyVideo";
<Player
component={MyVideo}
durationInFrames={150}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
style={{ width: "100%" }}
controls
inputProps={{ title: "Dynamic Title" }}
/>
AWS Lambda 渲染
npm i @remotion/lambda npx remotion lambda policies role # 设置 IAM npx remotion lambda sites create # 部署站点 npx remotion lambda render <site-url> MyVideo # 渲染
3D 视频制作(@remotion/three)
使用 React Three Fiber 在 Remotion 中创建 3D 动画视频。
适用场景
| 场景 | 说明 | 示例 |
|---|---|---|
| 产品展示 | 3D 模型旋转、拆解动画 | 手机产品宣传片 |
| 角色动画 | 卡通角色讲解、故事叙述 | 育儿科普视频 |
| 数据可视化 | 3D 图表、空间数据 | 地理信息、建筑展示 |
| Logo 动画 | 品牌 3D Logo 入场 | 片头片尾 |
安装
npm i three @react-three/fiber @remotion/three @types/three
官方模板(推荐新手):
npx create-video@latest --template three
基础示例
import { ThreeCanvas } from "@remotion/three";
import { useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
// 3D 场景组件
const My3DScene = () => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const camera = useThree((state) => state.camera);
// 设置相机
useEffect(() => {
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);
}, [camera]);
// 旋转动画
const rotation = interpolate(frame, [0, durationInFrames], [0, Math.PI * 2]);
// 弹性入场
const scale = spring({ frame, fps, config: { damping: 10, stiffness: 100 } });
return (
<mesh rotation={[0, rotation, 0]} scale={scale}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="royalblue" />
</mesh>
);
};
// 视频组件
export const My3DVideo = () => {
const { width, height } = useVideoConfig();
return (
<ThreeCanvas width={width} height={height}>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} />
<My3DScene />
</ThreeCanvas>
);
};
加载 GLTF 模型
import { useGLTF } from "@react-three/drei";
import { useCurrentFrame, interpolate } from "remotion";
const Model = () => {
const frame = useCurrentFrame();
const { scene } = useGLTF("/models/character.glb");
const rotation = interpolate(frame, [0, 150], [0, Math.PI * 2]);
return <primitive object={scene} rotation={[0, rotation, 0]} scale={0.5} />;
};
安装 drei(React Three Fiber 工具库):
npm i @react-three/drei
视频作为 3D 纹理
import { ThreeCanvas, useVideoTexture } from "@remotion/three";
import { staticFile, useVideoConfig } from "remotion";
const VideoOnMesh = () => {
const { width, height } = useVideoConfig();
const videoTexture = useVideoTexture(staticFile("/video.mp4"));
return (
<ThreeCanvas width={width} height={height}>
<mesh>
<planeGeometry args={[4, 3]} />
{videoTexture && <meshBasicMaterial map={videoTexture} />}
</mesh>
</ThreeCanvas>
);
};
渲染时使用 useOffthreadVideoTexture() 确保帧精确:
import { useOffthreadVideoTexture } from "@remotion/three";
const texture = useOffthreadVideoTexture({ src: staticFile("/video.mp4") });
3D 角色组合技巧
用基础几何体组合角色(无需专业建模):
// 简单卡通角色:头 + 身体 + 四肢
const CartoonCharacter = ({ emotion = "happy" }) => {
const frame = useCurrentFrame();
// 表情控制
const eyeScale = emotion === "happy" ? 1 : 0.5;
const mouthRotation = emotion === "happy" ? 0 : Math.PI;
// 走路动画:腿部摆动
const legSwing = Math.sin(frame * 0.2) * 0.3;
return (
<group>
{/* 头部 - 球体 */}
<mesh position={[0, 1.5, 0]}>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="#FFE4C4" />
</mesh>
{/* 身体 - 胶囊体 */}
<mesh position={[0, 0.5, 0]}>
<capsuleGeometry args={[0.3, 0.8, 16, 32]} />
<meshStandardMaterial color="#4169E1" />
</mesh>
{/* 左腿 */}
<mesh position={[-0.15, -0.3, 0]} rotation={[legSwing, 0, 0]}>
<cylinderGeometry args={[0.08, 0.08, 0.6]} />
<meshStandardMaterial color="#333" />
</mesh>
{/* 右腿 */}
<mesh position={[0.15, -0.3, 0]} rotation={[-legSwing, 0, 0]}>
<cylinderGeometry args={[0.08, 0.08, 0.6]} />
<meshStandardMaterial color="#333" />
</mesh>
</group>
);
};
⚠️ 踩坑经验
WebGL 上下文溢出
问题:多个 3D 场景同时渲染时报错 Error creating WebGL context
原因:浏览器限制 WebGL 上下文数量(通常 8-16 个)
解决方案:
- •渲染配置:使用
angleOpenGL 引擎
// remotion.config.ts
export default {
chromiumOptions: {
gl: "angle", // 或 "angle-egl"
},
};
CLI 渲染时:
npx remotion render --gl=angle MyVideo out.mp4
- •懒加载场景:只渲染当前帧附近的 3D 内容
import { useCurrentFrame } from "remotion";
const LazyScene = ({ sceneStart, sceneDuration, children }) => {
const frame = useCurrentFrame();
const buffer = 30; // 缓冲 30 帧
// 只在场景时间范围 ± buffer 内渲染
const shouldRender =
frame >= sceneStart - buffer &&
frame <= sceneStart + sceneDuration + buffer;
if (!shouldRender) {
return null; // 不渲染,释放 WebGL 上下文
}
return <>{children}</>;
};
// 使用
<Sequence from={0} durationInFrames={150}>
<LazyScene sceneStart={0} sceneDuration={150}>
<Scene1 />
</LazyScene>
</Sequence>
<Sequence from={150} durationInFrames={150}>
<LazyScene sceneStart={150} sceneDuration={150}>
<Scene2 />
</LazyScene>
</Sequence>
服务端渲染配置
服务端渲染(SSR)必须配置 gl 选项:
// renderMedia() / renderFrames() / getCompositions()
await renderMedia({
composition,
serveUrl,
outputLocation: "out.mp4",
chromiumOptions: {
gl: "angle",
},
});
Sequence 内的 useCurrentFrame
<Sequence> 内部的 useCurrentFrame() 返回的是相对于 Sequence 开始的帧号,不是全局帧号。
<Sequence from={60} durationInFrames={90}>
<MyScene /> {/* 这里 useCurrentFrame() 从 0 开始,不是 60 */}
</Sequence>
进阶资源
| 资源 | 用途 | 链接 |
|---|---|---|
| Mixamo | 免费骨骼动画库 | https://www.mixamo.com |
| Sketchfab | 免费/付费 3D 模型 | https://sketchfab.com |
| Ready Player Me | 虚拟人物生成 | https://readyplayer.me |
| Spline | 在线 3D 设计工具 | https://spline.design |
| gltfjsx | GLTF 转 React 组件 | npx gltfjsx model.glb |
进阶方向
- •Blender → GLTF:用 Blender 建模,导出 GLTF 格式,用
useGLTF加载 - •Mixamo 动画:下载 FBX 动画,转换为 GLTF,用
useAnimations播放 - •Spline 设计:在 Spline 设计 3D 场景,用
@splinetool/r3f-spline导入
3Blue1Brown 风格指南(教程类视频)
针对教程、讲解类视频,借鉴 3Blue1Brown 的可视化设计原则。
核心理念
3B1B 内核:让观众「自己发现」,而不是「被告知答案」
| 原则 | 说明 | 示例 |
|---|---|---|
| Why → What | 先提问为什么,再展示是什么 | "如何识别手写数字?" → 展示神经网络 |
| 逐步构建 | 元素一个个出现,不要整体淡入 | 神经元依次点亮,而非同时出现 |
| 颜色有语义 | 颜色传达信息,不是装饰 | 蓝=正、红=负、黄=高亮 |
| 数值具象化 | 显示具体数字让抽象概念落地 | 像素值 0.7、激活值 0.92 |
| 2D 优先 | 清晰优先于炫酷,必要时才用 3D | 网络结构用 2D,空间数据用 3D |
配色方案
// 3B1B 风格配色(语义化)
const COLORS_3B1B = {
background: "#000000", // 纯黑背景
positive: "#58C4DD", // 蓝色 - 正权重/正向
negative: "#FF6B6B", // 红色 - 负权重/负向
highlight: "#FFFF00", // 黄色 - 当前焦点/高亮
result: "#83C167", // 绿色 - 结果/正确
text: "#FFFFFF", // 白色 - 文字
neutral: "#888888", // 灰色 - 中性/未激活
accent: "#FF8C00", // 橙色 - 强调
};
// 使用示例
<meshStandardMaterial
color={weight > 0 ? COLORS_3B1B.positive : COLORS_3B1B.negative}
emissive={isHighlighted ? COLORS_3B1B.highlight : "#000"}
emissiveIntensity={isHighlighted ? 0.3 : 0}
/>
2D/3D 混合策略
| 内容类型 | 推荐维度 | 原因 |
|---|---|---|
| 网络结构图 | 2D | 层次清晰,易于标注 |
| 数据流向 | 2D + 动画箭头 | 强调顺序和因果 |
| 卷积操作 | 2D 俯视图 | 网格对齐,数值可见 |
| 特征图堆叠 | 2.5D(透视) | 展示深度/通道数 |
| 3D 物体识别 | 3D | 内容本身是 3D |
2D 模式实现:使用正交相机 + 扁平几何体
import { OrthographicCamera } from "@react-three/drei";
// 正交相机 = 无透视变形 = 2D 感觉
<OrthographicCamera makeDefault position={[0, 0, 10]} zoom={100} />
// 扁平几何体
<mesh>
<planeGeometry args={[1, 1]} /> {/* 2D 平面 */}
<meshBasicMaterial color={color} />
</mesh>
逐步构建动画
核心:用 delay 参数控制元素依次出现
// 批量元素逐个出现
const StaggeredGroup: React.FC<{
children: React.ReactNode[];
delayPerItem?: number
}> = ({ children, delayPerItem = 8 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<>
{React.Children.map(children, (child, i) => {
const delay = i * delayPerItem;
const progress = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 100 },
});
if (frame < delay) return null;
return (
<group scale={Math.max(0, progress)} opacity={progress}>
{child}
</group>
);
})}
</>
);
};
// 使用
<StaggeredGroup delayPerItem={10}>
<Neuron position={[0, 0, 0]} />
<Neuron position={[1, 0, 0]} />
<Neuron position={[2, 0, 0]} />
</StaggeredGroup>
数值标签组件
import { Text } from "@react-three/drei";
const ValueLabel: React.FC<{
value: number;
position: [number, number, number];
fontSize?: number;
}> = ({ value, position, fontSize = 0.15 }) => {
// 根据值选择颜色
const color = value > 0.5 ? COLORS_3B1B.positive :
value < -0.5 ? COLORS_3B1B.negative :
COLORS_3B1B.neutral;
return (
<Text
position={position}
fontSize={fontSize}
color={color}
anchorX="center"
anchorY="middle"
font="/fonts/JetBrainsMono-Regular.ttf" // 等宽字体
>
{value.toFixed(2)}
</Text>
);
};
高亮焦点组件
// 脉冲高亮框 - 引导注意力
const FocusBox: React.FC<{
position: [number, number, number];
size: [number, number];
label?: string;
}> = ({ position, size, label }) => {
const frame = useCurrentFrame();
const pulse = 1 + Math.sin(frame * 0.15) * 0.08;
return (
<group position={position}>
{/* 高亮框 */}
<mesh scale={[pulse, pulse, 1]}>
<planeGeometry args={size} />
<meshBasicMaterial
color={COLORS_3B1B.highlight}
transparent
opacity={0.2}
/>
</mesh>
{/* 边框 */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(...size)]} />
<lineBasicMaterial color={COLORS_3B1B.highlight} linewidth={2} />
</lineSegments>
{/* 标签 */}
{label && (
<Text position={[0, size[1] / 2 + 0.2, 0]} fontSize={0.12} color={COLORS_3B1B.highlight}>
{label}
</Text>
)}
</group>
);
};
脚本撰写指南(教程类)
❌ 宣布式(避免):
"首先是输入层。图像是一个数字矩阵。" "接下来是卷积层。卷积核在图像上滑动。"
✅ 探索式(推荐):
"你能轻松认出这是数字 7,但你能描述你是怎么做到的吗? (停顿 1 秒) 这正是神经网络要解决的问题。 让我们先看看计算机「看到」的是什么—— (数字网格逐个显示) 不是图像,而是 784 个数字。 那么问题来了:如何从这堆数字中识别出 7?"
脚本结构模板:
1. 🎯 提出问题(10%) - 用观众能共鸣的问题开场 - "你有没有想过..." 2. 🤔 直觉猜测(15%) - 引导观众思考可能的方案 - "也许我们可以..." 3. 🔍 逐步验证(50%) - 一步步展示机制 - 每一步都回答「为什么这样设计」 4. 📐 形式化(15%) - 展示数学公式(可选) - 将直觉转化为精确描述 5. 🎬 回顾总结(10%) - 完整流程快速回放 - 强调核心洞见
⚠️ 常见误区
| 误区 | 问题 | 改进 |
|---|---|---|
| 3D 炫技 | 旋转、透视分散注意力 | 用最简单的视角表达 |
| 颜色随意 | 红绿蓝只是装饰 | 建立颜色-含义映射 |
| 整体出现 | 观众不知道看哪里 | 逐个元素 + 高亮引导 |
| 只说 What | 观众不理解设计动机 | 先问 Why 再展示 What |
| 信息过载 | 一个场景塞太多概念 | 一个场景一个概念 |
过程动画模式(Process Animation)
核心理念:不只展示「是什么」,更要展示「怎么算」。让观众亲眼看到数据如何流动、计算如何发生。
适用场景
| 场景 | 说明 | 示例 |
|---|---|---|
| 算法可视化 | 展示每一步操作 | 排序、搜索、图遍历 |
| 数学公式推导 | 逐项展开计算 | 矩阵乘法、卷积运算 |
| 数据处理流程 | 输入→变换→输出 | CNN 前向传播、数据清洗 |
| 决策过程 | 比较、筛选、最终选择 | 池化取最大值、softmax |
动画模式分类
静态展示 → 结构动画 → 过程动画
↓ ↓ ↓
截图 元素出现 计算过程
淡入淡出 数据流动
相机移动 结果写入
过程动画组件库
1. 计算步骤展示(StepByStep)
// 逐步显示计算过程
const StepByStepCalc: React.FC<{
steps: string[]; // ["1×0.5", "+ 0×0.3", "+ 1×(-0.2)", "= 0.3"]
startFrame: number;
framesPerStep?: number;
}> = ({ steps, startFrame, framesPerStep = 20 }) => {
const frame = useCurrentFrame();
return (
<div style={{ fontFamily: "monospace", fontSize: 24, color: "white" }}>
{steps.map((step, i) => {
const stepStart = startFrame + i * framesPerStep;
const opacity = interpolate(frame, [stepStart, stepStart + 10], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const isResult = i === steps.length - 1;
return (
<span
key={i}
style={{
opacity,
color: isResult ? COLORS.result : COLORS.text,
fontWeight: isResult ? "bold" : "normal",
}}
>
{step}{" "}
</span>
);
})}
</div>
);
};
2. 数值飞入动画(ValueFlyIn)
// 计算结果飞入目标位置
const ValueFlyIn: React.FC<{
value: number;
from: [number, number, number];
to: [number, number, number];
startFrame: number;
duration?: number;
}> = ({ value, from, to, startFrame, duration = 30 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - startFrame,
fps,
config: { damping: 15, stiffness: 80 },
});
if (frame < startFrame) return null;
const position: [number, number, number] = [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
from[2] + (to[2] - from[2]) * progress,
];
const scale = 1.5 - 0.5 * progress; // 飞行时放大,落地时缩小
return (
<Text
position={position}
fontSize={0.12 * scale}
color={COLORS.result}
anchorX="center"
anchorY="middle"
>
{value.toFixed(1)}
</Text>
);
};
3. 区域高亮比较(CompareHighlight)
// 多个值依次比较,胜出者高亮
const CompareHighlight: React.FC<{
values: number[];
positions: [number, number, number][];
startFrame: number;
framesPerCompare?: number;
}> = ({ values, positions, startFrame, framesPerCompare = 15 }) => {
const frame = useCurrentFrame();
// 计算当前比较进度
const compareIndex = Math.floor((frame - startFrame) / framesPerCompare);
const maxIndex = values.indexOf(Math.max(...values));
return (
<>
{values.map((value, i) => {
const isComparing = i <= compareIndex && i <= maxIndex;
const isWinner = compareIndex >= values.length - 1 && i === maxIndex;
return (
<group key={i} position={positions[i]}>
<mesh>
<boxGeometry args={[0.2, 0.2, 0.02]} />
<meshStandardMaterial
color={isWinner ? COLORS.result : isComparing ? COLORS.highlight : COLORS.dim}
emissive={isWinner ? COLORS.result : "#000"}
emissiveIntensity={isWinner ? 0.5 : 0}
/>
</mesh>
<Text position={[0, 0, 0.02]} fontSize={0.08} color="#000">
{value}
</Text>
</group>
);
})}
</>
);
};
4. 滑动窗口(SlidingWindow)
// 卷积核/池化窗口滑动
const SlidingWindow: React.FC<{
gridSize: number; // 输入网格大小
windowSize: number; // 窗口大小 (3 for 3x3)
stride: number; // 步幅
currentStep: number; // 当前步骤 (0, 1, 2, ...)
onPositionChange?: (row: number, col: number) => void;
}> = ({ gridSize, windowSize, stride, currentStep }) => {
const outputSize = Math.floor((gridSize - windowSize) / stride) + 1;
const totalSteps = outputSize * outputSize;
const step = Math.min(currentStep, totalSteps - 1);
const row = Math.floor(step / outputSize) * stride;
const col = (step % outputSize) * stride;
// 窗口位置(相对于网格中心)
const pixelSize = 0.12;
const gap = 0.01;
const offset = (gridSize / 2 - 0.5) * (pixelSize + gap);
const windowOffset = (windowSize / 2 - 0.5) * (pixelSize + gap);
const x = col * (pixelSize + gap) - offset + windowOffset;
const y = row * (pixelSize + gap) - offset + windowOffset;
return (
<mesh position={[x, y, 0.05]}>
<boxGeometry args={[windowSize * pixelSize + (windowSize - 1) * gap,
windowSize * pixelSize + (windowSize - 1) * gap, 0.02]} />
<meshStandardMaterial
color={COLORS.negative}
transparent
opacity={0.6}
emissive={COLORS.negative}
emissiveIntensity={0.3}
/>
</mesh>
);
};
脚本撰写指南(过程动画版)
关键转变:脚本需要配合动画节奏,给动画「留白时间」。
❌ 传统脚本(信息密集):
"卷积核在图像上滑动,每到一个位置就做点乘运算,得到一个数值。" (一句话带过,观众还没看清发生了什么)
✅ 过程动画脚本(留白配合):
"让我们看看卷积是怎么计算的。" (停顿 - 窗口移动到位置) "卷积核覆盖了这 9 个像素。" (停顿 - 高亮 3x3 区域) "我们把每个像素值,和对应的权重相乘..." (停顿 - 逐步显示乘法) "然后把所有结果加起来。" (停顿 - 显示求和过程) "得到的这个数字,就写入特征图的对应位置。" (停顿 - 结果飞入) "第一个位置完成了。接下来,窗口向右滑动一格..." (加速展示后续步骤)
时间分配建议
| 详细程度 | 首次完整展示 | 重复加速 | 适用场景 |
|---|---|---|---|
| 极详细 | 3-4 秒/步 | 0.5 秒/步 | 核心概念首次出现 |
| 中等 | 2 秒/步 | 0.3 秒/步 | 辅助概念 |
| 快速 | 1 秒/步 | 闪过 | 已解释过的重复 |
示例:卷积场景时间分配
总时长:~25 秒
0-3s: 引入("让我们看看卷积是怎么计算的")
3-12s: 第 1 次卷积(完整详细展示)
- 窗口移动 (1s)
- 高亮区域 (1s)
- 计算过程 (4s)
- 结果飞入 (2s)
- 解说旁白 (1s)
12-18s: 第 2-3 次卷积(中等速度,简化解说)
18-23s: 剩余位置(快速滑动,仅显示结果)
23-25s: 展示完整特征图
⚠️ 过程动画踩坑经验
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 动画太快看不清 | 时间分配不足 | 增加关键步骤的帧数 |
| 解说与动画不同步 | 脚本没有留白 | 重写脚本,加入停顿标记 |
| 信息过载 | 一次展示太多 | 分阶段:先结构,再过程 |
| 重复内容无聊 | 每次都详细展示 | 首次详细 + 后续加速 |
| 数值太小看不见 | 3D 文字渲染问题 | 用 2D HTML overlay |
| 相机持续抖动 | 插值永不收敛 | 见下方「相机控制陷阱」 |
| 图像旋转90度 | 行列坐标映射反了 | 见下方「网格坐标陷阱」 |
| 进度显示好几千% | progress 变量未 clamp | Math.min(1, (frame - start) / duration) |
| 特征图只有色块无数值 | 组件缺少数值显示功能 | 添加 values + showValues 参数 |
进度变量必须 clamp
// ❌ 错误:场景持续时间可能远超预期,progress 会变成 5000% const calcProgress = frame > 30 ? (frame - 30) / 60 : 0; // ✅ 正确:限制在 [0, 1] 范围 const calcProgress = frame > 30 ? Math.min(1, (frame - 30) / 60) : 0;
特征图显示计算结果
// FeatureMap 组件应支持显示数值
<FeatureMap
position={[2, 0, 0]}
size={0.6}
count={1}
color={COLORS.result}
filledCells={filledCount}
gridSize={6}
values={[2, -1, 0, 3, ...]} // 每个格子的计算结果
showValues // 启用数值显示
/>
🚨 3D 场景常见陷阱
陷阱 1:相机持续抖动
症状:画面一直微微放大-缩小抖动
错误写法:
// ❌ 永远无法精确到达目标,导致持续微抖动
const CameraController = ({ targetZ }) => {
const { camera } = useThree();
const frame = useCurrentFrame();
useEffect(() => {
camera.position.z += (targetZ - camera.position.z) * 0.05;
}, [frame]);
return null;
};
正确写法:
// ✅ 方案A:使用 spring 动画(推荐)
const CameraController = ({ targetZ, transitionFrame = 0 }) => {
const { camera } = useThree();
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const z = spring({
frame: frame - transitionFrame,
fps,
from: camera.position.z,
to: targetZ,
config: { damping: 20, stiffness: 100 },
});
camera.position.z = z;
return null;
};
// ✅ 方案B:直接设置(无过渡)
const CameraController = ({ targetZ }) => {
const { camera } = useThree();
camera.position.set(0, 0, targetZ);
camera.lookAt(0, 0, 0);
return null;
};
// ✅ 方案C:插值但加阈值
useEffect(() => {
const delta = targetZ - camera.position.z;
if (Math.abs(delta) < 0.001) {
camera.position.z = targetZ; // 接近时直接设置
} else {
camera.position.z += delta * 0.1;
}
}, [frame]);
陷阱 2:网格图像旋转90度
症状:本应显示为正常方向的图像(如数字7)被旋转了90度
根因:图像处理中 row 对应 y 轴(从上到下),col 对应 x 轴(从左到右),
但代码里把行索引映射到了 x 坐标,列索引映射到了 y 坐标。
错误写法:
// ❌ row 映射到 x,col 映射到 y,图像会旋转90度
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
const x = (row - size/2) * cellSize; // 错!row 应该是 y
const y = (col - size/2) * cellSize; // 错!col 应该是 x
// ...
}
}
正确写法:
// ✅ col 映射到 x,row 映射到 y(且 y 要翻转)
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
const x = (col - size/2 + 0.5) * cellSize; // col → x
const y = ((size - 1 - row) - size/2 + 0.5) * cellSize; // row → y(翻转)
// ...
}
}
记忆口诀:
- •图像坐标:
image[row][col]=image[y][x](行是y,列是x) - •3D 坐标:x 向右,y 向上
- •翻转 row:图像 row=0 在顶部,3D y=max 在顶部
工作流最佳实践
推荐的 npm scripts 配置
{
"scripts": {
"dev": "remotion studio",
"audio": "npx ts-node scripts/generate-audio.ts",
"audio:edge": "npx ts-node scripts/generate-audio.ts -p edge",
"audio:qwen": "npx ts-node scripts/generate-audio.ts -p qwen",
"render": "remotion render MyVideo out/video.mp4",
"build": "npm run audio && npm run render",
"check-env": "bash scripts/check-env.sh"
}
}
实时进度显示
音频生成和视频渲染都可能耗时较长,务必使用前台执行以便看到进度:
# ✅ 推荐:前台执行,实时显示进度 npm run audio npm run render # ✅ 或者用 shell 脚本封装 bash scripts/render.sh # ❌ 避免:后台执行看不到进度 npm run render &
render.sh 示例:
#!/bin/bash
cd "$(dirname "$0")/.."
echo "🎬 开始渲染视频..."
npx remotion render MyVideo out/video.mp4
if [ $? -eq 0 ]; then
echo "✅ 渲染完成!"
ls -lh out/video.mp4
else
echo "❌ 渲染失败"
exit 1
fi
断点续作设计原则
长时间任务(如批量生成音频)应支持断点续作:
- •检查已存在文件:跳过已完成的项目
- •原子操作:单个文件生成失败不影响已完成的
- •进度保存:失败时保留已完成的部分
- •幂等执行:重复运行产生相同结果
调试技巧
- •Studio 热重载:
npm run dev实时预览 - •检查帧:Studio 中拖动时间轴逐帧检查
- •性能:避免在组件内做重计算,用
useMemo - •静态文件:放在
public/目录,用staticFile()引用
常见问题
Q: 视频渲染很慢?
- •使用
--concurrency增加并行数 - •降低分辨率测试:
--scale=0.5 - •考虑 AWS Lambda 分布式渲染
Q: 字体不显示?
- •使用
@remotion/google-fonts或本地加载 - •确保字体在渲染前已加载
Q: 视频素材不播放?
- •检查视频编码格式(推荐 H.264)
- •使用
<OffthreadVideo>替代<Video>提升性能