
Remotion Video
- 938 installs
- 329 repo stars
- Updated January 25, 2026
- wshuyi/remotion-video-skill
remotion-video is a Claude Code skill that programmatically generates animated videos, data visualizations, explainers, and product demos using React components and the Remotion framework for developers who need code-dri
About
remotion-video is a skill for creating videos with Remotion, a React framework where compositions define dimensions, frame rate, and duration while hooks like useCurrentFrame, interpolate, and spring drive motion on a Sequence timeline. It supports programmatic batch generation, data-driven annual summaries, music visualizations, auto captions, technical concept explainers, 3D product showcases, and logo animations across eleven documented use cases. Projects bootstrap with npx create-video@latest. Developers reach for remotion-video when MP4 output must be repeatable, parameterized, and version-controlled like application code rather than edited manually.
- Create MP4 videos with React components and Remotion APIs
- Supports data-driven batch generation, music visualization, and automatic subtitles
- 11 distinct video use cases including 3D product animations, technical concept explainers, and logo animations
- Real-time preview with Remotion Studio and spring-based physics animations
- Integrates with AI agents for fully programmatic video pipelines
Remotion Video by the numbers
- 938 all-time installs (skills.sh)
- +13 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #295 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wshuyi/remotion-video-skill --skill remotion-videoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 938 |
|---|---|
| repo stars | ★ 329 |
| Security audit | 2 / 3 scanners passed |
| Last updated | January 25, 2026 |
| Repository | wshuyi/remotion-video-skill ↗ |
How do you generate MP4 videos with React Remotion?
Programmatically generate animated videos, data visualizations, explainers, and product demos using React components.
Who is it for?
Frontend developers who want React-component video pipelines for tutorials, data visualizations, or batch-generated marketing clips.
Skip if: Developers needing Python mathematical animations or one-off cinematic AI clips should use Manim or Replicate prompt skills instead of remotion-video.
When should I use this skill?
The user mentions Remotion, programmatic React video, code-driven MP4 export, or batch video generation from data.
What you get
Remotion React compositions, rendered MP4 video files, and reusable animation components for batch or data-driven output.
- Remotion composition source
- Rendered MP4 video files
By the numbers
- Documents 11 Remotion use-case scenarios in the skill README
- Bootstraps new projects with npx create-video@latest
Files
Remotion Video
用 React 以编程方式创建 MP4 视频的框架。
核心概念
1. Composition - 视频的定义(尺寸、帧率、时长) 2. useCurrentFrame() - 获取当前帧号,驱动动画 3. interpolate() - 将帧号映射到任意值(位置、透明度等) 4. spring() - 物理动画效果 5. <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 语音解说,实现音视频同步。支持两种方案:
| 方案 | 优点 | 缺点 | 硬件要求 | 推荐度 |
|---|---|---|---|---|
| MiniMax TTS | 云端克隆、速度极快(<3秒)、音质优秀 | 按字符计费 | 无 | ⭐⭐⭐ 首选 |
| Edge TTS | 零配置、免费 | 固定音色、无法自定义 | 无 | ⭐⭐ |
方案选择流程
1. 首选 MiniMax TTS
- 检测 API Key 是否配置
- 测试调用是否正常(余额充足)
- 如果成功 → 使用 MiniMax
2. MiniMax 不可用时
→ 退回 Edge TTS(使用预设音色 zh-CN-YunyangNeural)---
方案一:MiniMax TTS(推荐)
云端 API 方案,无需本地 GPU,生成速度极快,音色克隆效果优秀。
配置
1. 注册 https://www.minimax.io (国际版)或 https://platform.minimaxi.com (国内版) 2. 获取 API Key 3. 在 MiniMax Audio 上传音频克隆音色,获取 voice_id
API 差异
| 版本 | API 域名 | 说明 |
|---|---|---|
| 国际版 | api.minimax.io | 推荐,稳定 |
| 国内版 | api.minimaxi.com | 需国内账号 |
⚠️ 常见错误:api.minimax.chat 是错误的域名,会返回 "invalid api key"。请确认使用上表中的正确域名。
生成脚本
使用 scripts/generate_audio_minimax.py 生成音频,支持:
- 断点续作:已存在的音频文件自动跳过
- 实时进度:显示生成进度,避免茫然等待
- 自动更新配置:生成完成后自动更新 Remotion 的场景配置
# 设置环境变量
export MINIMAX_API_KEY="your_api_key"
export MINIMAX_VOICE_ID="your_voice_id"
# 运行脚本
python scripts/generate_audio_minimax.py价格参考(2025年)
| 模型 | 价格 |
|---|---|
| speech-02-hd | ¥0.1/千字符 |
| speech-02-turbo | ¥0.05/千字符 |
⚠️ MiniMax TTS 踩坑经验
| 问题 | 原因 | 解决方案 |
|---|---|---|
invalid api key | 使用了错误的 API 域名 | 国际版用 api.minimax.io,国内版用 api.minimaxi.com |
config.ts 语法错误 Syntax error "n" | Python 脚本在 f-string 中用 ",\\n".join() 产生了字面量 \n 而非真正换行 | 见下方「Python 生成 TypeScript 注意事项」 |
| 长时间无进度显示 | 后台执行命令看不到输出 | 前台执行脚本,或用 tail -f 实时查看日志 |
Python 生成 TypeScript 注意事项
❌ 错误写法:在 f-string 中使用 \n 会产生字面量字符
# 这会在生成的文件中写入字面的 \n 字符串,而非换行!
content = f'export const SCENES = [{",\\n".join(items)}];'✅ 正确写法:分开处理字符串拼接
# 先用真正的换行符拼接
scenes_content = ",\n".join(items) # 在 f-string 外部拼接
# 再放入模板
content = f'''export const SCENES = [
{scenes_content}
];'''---
方案二:Edge TTS
无需特殊硬件,完全免费,适合不需要克隆音色的场景。
安装
pip install edge-tts推荐语音
| 语音 ID | 名称 | 风格 |
|---|---|---|
| zh-CN-YunyangNeural | 云扬 | 专业播音腔(推荐) |
| zh-CN-XiaoxiaoNeural | 晓晓 | 温暖自然 |
| zh-CN-YunxiNeural | 云希 | 阳光少年 |
生成脚本
使用 scripts/generate_audio_edge.py 生成音频:
python scripts/generate_audio_edge.pyRemotion 音频同步
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 → 场景组件 → 视频渲染关键思想: 1. 音频决定时长:每个场景的持续时间由音频长度决定 2. 场景即章节:一个概念 = 一个场景 = 一段音频 3. 配置即真理: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 场景 |
| 视频太简略 | 只有一个大场景 | 一个概念 = 一个场景组件,分层讲解 |
场景组件设计原则
1. 单一职责:每个场景组件只负责一个概念 2. 独立动画:每个场景有自己的 useCurrentFrame(),动画从 0 开始 3. 延迟出现:用 delay 参数控制元素依次出现 4. 相机适配:不同场景可能需要不同相机位置
// 场景组件示例
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 # 安装 Whisperimport { transcribe } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "audio.mp3",
whisperPath: whisperCppPath,
model: "medium",
});播放器嵌入 Web 应用
npm i @remotion/playerimport { 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 个)
解决方案:
1. 渲染配置:使用 angle OpenGL 引擎
// remotion.config.ts
export default {
chromiumOptions: {
gl: "angle", // 或 "angle-egl"
},
};CLI 渲染时:
npx remotion render --gl=angle MyVideo out.mp42. 懒加载场景:只渲染当前帧附近的 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 |
进阶方向
1. Blender → GLTF:用 Blender 建模,导出 GLTF 格式,用 useGLTF 加载 2. Mixamo 动画:下载 FBX 动画,转换为 GLTF,用 useAnimations 播放 3. 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": "python3 scripts/generate_audio.py",
"render": "remotion render MyVideo out/video.mp4",
"build": "npm run audio && npm run render"
}
}实时进度显示
音频生成和视频渲染都可能耗时较长,务必使用前台执行以便看到进度:
# ✅ 推荐:前台执行,实时显示进度
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断点续作设计原则
长时间任务(如批量生成音频)应支持断点续作:
1. 检查已存在文件:跳过已完成的项目 2. 原子操作:单个文件生成失败不影响已完成的 3. 进度保存:失败时保留已完成的部分 4. 幂等执行:重复运行产生相同结果
调试技巧
1. Studio 热重载:npm run dev 实时预览 2. 检查帧:Studio 中拖动时间轴逐帧检查 3. 性能:避免在组件内做重计算,用 useMemo 4. 静态文件:放在 public/ 目录,用 staticFile() 引用
常见问题
Q: 视频渲染很慢?
- 使用
--concurrency增加并行数 - 降低分辨率测试:
--scale=0.5 - 考虑 AWS Lambda 分布式渲染
Q: 字体不显示?
- 使用
@remotion/google-fonts或本地加载 - 确保字体在渲染前已加载
Q: 视频素材不播放?
- 检查视频编码格式(推荐 H.264)
- 使用
<OffthreadVideo>替代<Video>提升性能
参考资源
- 官方文档:https://remotion.dev/docs
- 模板库:https://remotion.dev/templates
- GitHub:https://github.com/remotion-dev/remotion
remotion-video
English
使用 Remotion 框架编程式创建视频的 Claude Code Skill。
功能特点
- 使用 React 组件编程式创建视频
- AI 驱动的 TTS 音频生成(MiniMax 或 Edge TTS)
- 基于场景的架构,自动计算时长
- 支持动画、字幕、音乐可视化
安装
Claude Code 用户
将整个 remotion-video 文件夹复制到 Claude Code skills 目录:
git clone https://github.com/wshuyi/remotion-video-skill.git
cp -r remotion-video-skill ~/.claude/skills/remotion-video然后重启 Claude Code 或开启新会话。
TTS 配置(二选一)
方案 A:Edge TTS(免费,推荐快速上手)
无需配置!Edge TTS 免费且开箱即用。
只需安装 Python 依赖:
pip install edge-tts方案 B:MiniMax TTS(付费,支持声音克隆)
1. 从 MiniMax 平台 获取 API 密钥 2. 设置环境变量:
# 添加到 ~/.zshrc 或 ~/.bashrc
export MINIMAX_API_KEY="你的API密钥"
export MINIMAX_VOICE_ID="你的音色ID"获取 Voice ID:
- 使用 MiniMax 内置音色,或
- 在平台上克隆自己的声音
使用方法
安装后,通过以下方式触发:
- "用代码做视频"
- "编程视频"
- "Remotion"
- "/remotion-video"
示例提示词
教程视频:
帮我做一个讲解 Python 装饰器的教程视频,5分钟左右
数据可视化:
用 Remotion 做一个展示2024年销售数据的动画视频
音乐可视化:
帮我做一个音乐可视化视频,配合这首歌的节奏
项目结构
Skill 创建的项目结构:
my-video-project/
├── src/
│ ├── Root.tsx # 主组合
│ ├── audioConfig.ts # 场景时长配置(自动生成)
│ └── scenes/ # 场景组件
├── public/
│ └── audio/ # TTS 音频文件
├── scripts/
│ └── generate_audio.py # TTS 生成脚本
└── package.json环境要求
- Node.js 18+
- Python 3.8+(用于 TTS)
- ffprobe(用于检测音频时长)
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg许可证
MIT
remotion-video
中文文档
A Claude Code Skill for creating programmatic videos with Remotion framework.
Features
- Create videos programmatically with React components
- AI-powered TTS audio generation (MiniMax or Edge TTS)
- Scene-based architecture with automatic timing
- Support for animations, subtitles, and music visualization
Installation
For Claude Code Users
Copy this entire remotion-video folder to your Claude Code skills directory:
cp -r remotion-video ~/.claude/skills/Then restart Claude Code or start a new session.
TTS Setup (Choose One)
Option A: Edge TTS (Free, Recommended for Quick Start)
No setup required! Edge TTS is free and works out of the box.
Just install the Python dependency:
pip install edge-ttsOption B: MiniMax TTS (Paid, Voice Cloning Support)
1. Get your API key from MiniMax Platform 2. Set environment variables:
# Add to your ~/.zshrc or ~/.bashrc
export MINIMAX_API_KEY="your-api-key-here"
export MINIMAX_VOICE_ID="your-voice-id-here"To get a Voice ID:
- Use MiniMax's built-in voices, or
- Clone your own voice on their platform
Usage
After installation, trigger the skill by saying:
- "用代码做视频"
- "编程视频"
- "Remotion"
- "/remotion-video"
Example Prompts
Tutorial Video:
帮我做一个讲解 Python 装饰器的教程视频,5分钟左右
Data Visualization:
用 Remotion 做一个展示2024年销售数据的动画视频
Music Visualization:
帮我做一个音乐可视化视频,配合这首歌的节奏
Project Structure
When the skill creates a new project:
my-video-project/
├── src/
│ ├── Root.tsx # Main composition
│ ├── audioConfig.ts # Scene timing (auto-generated)
│ └── scenes/ # Scene components
├── public/
│ └── audio/ # TTS audio files
├── scripts/
│ └── generate_audio.py # TTS generation script
└── package.jsonRequirements
- Node.js 18+
- Python 3.8+ (for TTS)
- ffprobe (for audio duration detection)
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpegLicense
MIT
#!/usr/bin/env python3
"""
Edge TTS 音频生成脚本
特性:
- 免费、无需 API Key
- 支持多种中文语音
- 自动获取音频时长
用法:
python scripts/generate_audio_edge.py
依赖:
pip install edge-tts
"""
import asyncio
import subprocess
from pathlib import Path
try:
import edge_tts
except ImportError:
print("❌ 请先安装 edge-tts: pip install edge-tts")
exit(1)
# 场景配置
SCENES = [
{"id": "01-intro", "text": "欢迎观看本期视频..."},
{"id": "02-main", "text": "今天我们来讲..."},
]
# 推荐语音
# zh-CN-YunyangNeural - 云扬(专业播音腔,推荐)
# zh-CN-XiaoxiaoNeural - 晓晓(温暖自然)
# zh-CN-YunxiNeural - 云希(阳光少年)
VOICE = "zh-CN-YunyangNeural"
OUTPUT_DIR = Path(__file__).parent.parent / "public" / "audio"
def get_audio_duration(file_path: Path) -> float:
"""用 ffprobe 获取音频时长"""
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
capture_output=True, text=True
)
return float(result.stdout.strip()) if result.stdout.strip() else 0
async def generate_audio(scene: dict, output_dir: Path) -> dict:
"""生成单个场景的音频"""
output_file = output_dir / f"{scene['id']}.mp3"
communicate = edge_tts.Communicate(scene["text"], VOICE)
await communicate.save(str(output_file))
duration = get_audio_duration(output_file)
frames = round(duration * 30) # 30fps
return {"id": scene["id"], "file": f"{scene['id']}.mp3", "duration": duration, "frames": frames}
async def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"🎙️ Edge TTS (Voice: {VOICE})")
print(f"📁 输出目录: {OUTPUT_DIR}")
print("=" * 60)
results = []
for i, scene in enumerate(SCENES, 1):
print(f"[{i}/{len(SCENES)}] {scene['id']}: 生成中...", end=" ", flush=True)
try:
result = await generate_audio(scene, OUTPUT_DIR)
results.append(result)
print(f"✅ {result['duration']:.2f}s ({result['frames']} frames)")
except Exception as e:
print(f"❌ {e}")
print("=" * 60)
print(f"✅ 完成: {len(results)} 个音频文件")
# 输出配置供 Remotion 使用
print("\n📝 audioConfig 数据:")
for r in results:
print(f' {{ id: "{r["id"]}", file: "{r["file"]}", frames: {r["frames"]} }},')
if __name__ == "__main__":
asyncio.run(main())
#!/usr/bin/env python3
"""
MiniMax TTS 音频生成脚本(支持断点续作)
特性:
- 检测已存在的音频文件,自动跳过
- 实时显示生成进度
- 生成失败时保留已完成的部分
- 自动更新 Remotion 配置文件
用法:
python scripts/generate_audio_minimax.py
环境变量:
MINIMAX_API_KEY: MiniMax API 密钥
MINIMAX_VOICE_ID: 克隆音色 ID
"""
import os
import requests
import subprocess
from pathlib import Path
# 从环境变量读取配置(推荐)
API_KEY = os.environ.get("MINIMAX_API_KEY")
VOICE_ID = os.environ.get("MINIMAX_VOICE_ID")
if not API_KEY or not VOICE_ID:
print("❌ 错误: 请设置 MINIMAX_API_KEY 和 MINIMAX_VOICE_ID 环境变量")
exit(1)
# 场景配置 - 每个场景包含 id、title、text
SCENES = [
{"id": "01-intro", "title": "开场", "text": "欢迎观看本期视频..."},
{"id": "02-concept", "title": "核心概念", "text": "今天我们来讲..."},
{"id": "03-demo", "title": "演示", "text": "让我们看一个例子..."},
{"id": "04-summary", "title": "总结", "text": "感谢观看,下期见!"},
]
OUTPUT_DIR = Path(__file__).parent.parent / "public" / "audio"
CONFIG_FILE = Path(__file__).parent.parent / "src" / "audioConfig.ts"
def get_audio_duration(file_path: Path) -> float:
"""用 ffprobe 获取音频时长"""
result = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
capture_output=True, text=True,
)
return float(result.stdout.strip()) if result.stdout.strip() else 0
def generate_audio(scene: dict) -> dict:
"""使用 MiniMax API 生成音频"""
url = "https://api.minimax.io/v1/t2a_v2" # 国际版
# url = "https://api.minimaxi.com/v1/t2a_v2" # 国内版
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": "speech-02-hd",
"text": scene["text"],
"stream": False,
"voice_setting": {"voice_id": VOICE_ID, "speed": 1.0, "vol": 1.0, "pitch": 0},
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3", "channel": 1},
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
result = response.json()
if "data" in result and "audio" in result["data"]:
audio_data = bytes.fromhex(result["data"]["audio"])
output_file = OUTPUT_DIR / f"{scene['id']}.mp3"
output_file.write_bytes(audio_data)
duration = result.get("extra_info", {}).get("audio_length", 0) / 1000
if duration == 0:
duration = get_audio_duration(output_file)
return {"id": scene["id"], "title": scene["title"], "file": f"{scene['id']}.mp3", "duration": duration, "frames": round(duration * 30)}
else:
error = result.get("base_resp", {}).get("status_msg", str(result))
raise Exception(f"API 错误: {error}")
def main():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"🎙️ MiniMax TTS (Voice: {VOICE_ID[:20]}...)")
print(f"📁 输出目录: {OUTPUT_DIR}")
print("=" * 60)
results = []
skipped = 0
generated = 0
for i, scene in enumerate(SCENES, 1):
output_file = OUTPUT_DIR / f"{scene['id']}.mp3"
prefix = f"[{i}/{len(SCENES)}] {scene['id']}"
# 断点续作:检查文件是否已存在
if output_file.exists() and output_file.stat().st_size > 0:
duration = get_audio_duration(output_file)
frames = round(duration * 30)
results.append({"id": scene["id"], "title": scene["title"], "file": f"{scene['id']}.mp3", "duration": duration, "frames": frames})
print(f"{prefix}: ⏭️ 已存在,跳过 ({duration:.2f}s)")
skipped += 1
continue
# 生成新音频
print(f"{prefix}: 生成中...", end=" ", flush=True)
try:
result = generate_audio(scene)
results.append(result)
print(f"✅ {result['duration']:.2f}s ({result['frames']} frames)")
generated += 1
except Exception as e:
print(f"❌ {e}")
print("\n⚠️ 生成中断,已完成的音频已保存,可重新运行继续")
return
print("=" * 60)
print(f"✅ 完成: {generated} 新生成, {skipped} 跳过")
# 更新 audioConfig.ts
update_config(results)
print(f"📝 audioConfig.ts 已更新")
def update_config(results):
"""更新 audioConfig.ts - 注意:必须用真正的换行符,不能用字符串 \\n"""
scenes_lines = []
for r in results:
# 使用多行字符串确保正确的换行
scene_block = f''' {{
id: "{r['id']}",
title: "{r['title']}",
durationInFrames: {r['frames']},
audioFile: "{r['file']}",
}}'''
scenes_lines.append(scene_block)
# 用真正的换行符连接,不要用 ",\\n".join()
scenes_content = ",\n".join(scenes_lines)
content = f'''// 场景配置(MiniMax TTS 生成)
// 自动生成,请勿手动修改
export interface SceneConfig {{
id: string;
title: string;
durationInFrames: number;
audioFile: string;
}}
export const SCENES: SceneConfig[] = [
{scenes_content},
];
// 计算场景起始帧
export function getSceneStart(sceneIndex: number): number {{
return SCENES.slice(0, sceneIndex).reduce((sum, s) => sum + s.durationInFrames, 0);
}}
// 总帧数(加上片头片尾缓冲)
export const TOTAL_FRAMES = SCENES.reduce((sum, s) => sum + s.durationInFrames, 0) + 60;
// 帧率
export const FPS = 30;
'''
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(content)
if __name__ == "__main__":
main()
// 场景配置(TTS 生成后自动更新)
export interface SceneConfig {
id: string;
title: string;
durationInFrames: number;
audioFile: string;
}
export const SCENES: SceneConfig[] = [
{ id: "01-intro", title: "开场", durationInFrames: 300, audioFile: "01-intro.mp3" },
{ id: "02-concept", title: "核心概念", durationInFrames: 450, audioFile: "02-concept.mp3" },
{ id: "03-demo", title: "演示", durationInFrames: 600, audioFile: "03-demo.mp3" },
// ... 更多场景
];
// 计算场景起始帧
export function getSceneStart(sceneIndex: number): number {
return SCENES.slice(0, sceneIndex).reduce((sum, s) => sum + s.durationInFrames, 0);
}
// 总帧数(可加片头片尾缓冲)
export const TOTAL_FRAMES = SCENES.reduce((sum, s) => sum + s.durationInFrames, 0) + 60;
// 帧率
export const FPS = 30;
Related skills
How it compares
Choose remotion-video for React-timeline MP4 rendering; pick manim-video for Python math animations or prompt-videos for Replicate AI clips.
FAQ
How do you start a Remotion project with remotion-video?
remotion-video directs developers to bootstrap with npx create-video@latest, then define Composition settings and animate with useCurrentFrame, interpolate, spring, and Sequence components before rendering MP4 output.
What can remotion-video render beyond simple clips?
remotion-video documents eleven scenarios including batch generation, data-driven summaries, music visualizations, auto subtitles, technical explainers, 3D product showcases, and logo animations—all as React-driven Remotion compositions.
Is Remotion Video safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.