技术拆解-remotion
https://github.com/remotion-dev/remotion
我关注的其实就一个-帧率限制分析:Remotion 是否受浏览器帧率限制?

问题
Remotion 录制的视频会不会受限于浏览器的 30 帧或 60 帧限制?
答案:不会。 Remotion 可以渲染任意帧率的视频,不受浏览器刷新率限制。
传统录制 vs Remotion 渲染
传统屏幕录制
graph LR
A[浏览器实时播放] -->|限制在屏幕刷新率| B[60Hz 显示器]
B --> C[录制软件捕获]
C --> D[输出视频 ≤ 60fps]
style B fill:#ffcccc
style D fill:#ffcccc
限制:
-
受限于屏幕刷新率(通常 60Hz)
-
实时录制,无法跳帧
-
丢帧后无法恢复
Remotion 渲染
graph TD
A[定义视频配置<br/>fps: 任意值] --> B[逐帧渲染循环]
B --> C[seekToFrame N]
C --> D[等待渲染完成]
D --> E[screenshot 截图]
E --> F{还有帧?}
F -->|是| C
F -->|否| G[拼接视频]
G --> H[输出视频<br/>精确帧率]
style A fill:#ccffcc
style H fill:#ccffcc
优势:
-
不依赖实时播放
-
每帧独立控制
-
可设置任意帧率
-
无丢帧风险
技术原理
1. 逐帧渲染机制
Remotion 不是实时录制浏览器屏幕,而是逐帧控制渲染:
// packages/renderer/src/render-frames.ts
// 渲染循环(伪代码)
for (const frame of framesToRender) {
// 1. 从页面池获取一个页面
const page = await pool.acquire();
// 2. 跳转到指定帧(不依赖浏览器刷新)
await seekToFrame({
page,
frame, // 可以是任意数字:0, 1, 2, ..., 9999
composition,
timeoutInMilliseconds,
});
// 3. 等待该帧完全渲染
await waitForReady({
page,
frame
});
// 4. 截图保存
const buffer = await page.screenshot({
type: 'jpeg',
quality: 80,
});
// 5. 保存或处理该帧
saveFrame(buffer, frame);
// 6. 释放页面回池
pool.release(page);
}
关键点:
-
seekToFrame()可以跳转到任意帧号,不受浏览器刷新率限制 -
每帧渲染完成后才截图,不依赖实时播放
-
可以设置
timeoutInMilliseconds给每帧足够的时间渲染
2. 帧号计算机制
帧号是通过计算得出的,不是通过测量浏览器刷新:
// packages/core/src/use-current-frame.ts
export const useCurrentFrame = (): number => {
// 从状态中获取当前时间线位置
const frame = useTimelinePosition();
// 获取 Sequence 上下文
const context = useContext(SequenceContext);
// 计算偏移量
const contextOffset = context
? context.cumulatedFrom + context.relativeFrom
: 0;
// 返回相对帧号(直接计算)
return frame - contextOffset;
};
说明:
-
帧号是整数,可以直接计算:0, 1, 2, 3, …
-
不依赖于
requestAnimationFrame的回调频率 -
Remotion 内部维护自己的时间线状态
3. Puppeteer 的能力
Remotion 使用 Puppeteer 控制 headless Chrome,它提供了:
// 1. 精确的帧控制
await page.evaluate((frame) => {
window.remotion_setFrame(frame);
}, frame);
// 2. 等待渲染完成
await page.waitForFunction(() => {
return window.remotion_renderReady === true;
});
// 3. 截图(不受刷新率限制)
const screenshot = await page.screenshot({
type: 'jpeg',
quality: 80,
});
实际测试案例
测试 1: 超高帧率
// 测试 120 fps
export const Test120FPS = () => {
return (
<Composition
id="test-120fps"
component={FrameCounter}
fps={120} // 120 fps
durationInFrames={1200} // 10 秒
width={1920}
height={1080}
/>
);
};
export const FrameCounter = () => {
const frame = useCurrentFrame();
return (
<div style={{
fontSize: 100,
backgroundColor: 'black',
color: 'white'
}}>
Frame: {frame}
</div>
);
};
预期结果:
-
渲染 1200 帧
-
每秒 120 帧
-
帧号精确:0, 1, 2, …, 1199
-
视频时长:10 秒
测试 2: 极高帧率
// 测试 240 fps
<Composition
fps={240} // 240 fps
durationInFrames={2400} // 10 秒
/>
预期结果:
-
渲染 2400 帧
-
每秒 240 帧
-
视频时长:10 秒
-
完全不受浏览器 60Hz 限制
测试 3: 非标准帧率
// 电影帧率
<Composition fps={24} durationInFrames={240} />
// PAL 视频帧率
<Composition fps={25} durationInFrames={250} />
// NTSC 视频帧率
<Composition fps={29.97} durationInFrames={2997} />
// 高帧率游戏
<Composition fps={144} durationInFrames={1440} />
所有这些都可以正常工作!
为什么不会受限制?
原因 1: 非实时渲染
// ❌ 传统方式(受限制)
浏览器播放 → requestAnimationFrame 回调 → 录制屏幕
// 限制:requestAnimationFrame 最高约 60fps(受屏幕刷新率限制)
// ✅ Remotion 方式(无限制)
逐帧控制 → 每帧独立渲染 → 截图保存 → 拼接视频
// 无限制:帧号是计算得出的,可以是任意整数
原因 2: 虚拟时间线
Remotion 维护了一个虚拟时间线:
// packages/core/src/timeline-position-state.ts
type TimelineContext = {
frame: Record<string, number>; // 每个合成的当前帧
playing: boolean;
};
// 这个时间线完全由 Remotion 控制
// 不受浏览器 requestAnimationFrame 的影响
原因 3: Puppeteer 的页面隔离
每个渲染页面都是独立的:
// packages/renderer/src/render-frames.ts
// 创建多个页面
const pages = await Promise.all([
makePage(frame: 0),
makePage(frame: 1),
makePage(frame: 2),
makePage(frame: 3),
]);
// 每个页面渲染不同的帧
// 它们之间互不影响,也不受浏览器刷新率影响
实际限制是什么?
虽然没有帧率限制,但有其他限制:
1. 渲染速度(性能)
// 每帧渲染时间 × 总帧数 = 总渲染时间
// 示例:
// - 简单场景:每帧 10ms
// 1000 帧 × 10ms = 10 秒
// - 复杂场景:每帧 100ms
// 1000 帧 × 100ms = 100 秒
// - 非常复杂:每帧 1000ms
// 1000 帧 × 1000ms = 1000 秒(约 17 分钟)
优化:
// 并发渲染可以加快速度
concurrency: 4 // 同时渲染 4 帧
// 可以将渲染时间缩短到约 1/4
2. 内存使用
// 高帧率 = 更多帧 = 更多内存
// 1080p @ 30fps, 10秒, JPEG
// 300 帧 × 500KB = 150MB
// 1080p @ 60fps, 10秒, JPEG
// 600 帧 × 500KB = 300MB(2倍)
// 1080p @ 120fps, 10秒, JPEG
// 1200 帧 × 500KB = 600MB(4倍)
3. 磁盘空间
// 临时文件和最终输出
// 高帧率需要更多存储空间
// 使用并行编码可以减少磁盘使用
onFrameBuffer: async (buffer, frame) => {
// 直接通过管道传给 FFmpeg
// 不保存为中间文件
await pipeToFFmpeg(buffer);
}
4. FFmpeg 编码能力
// FFmpeg 可以处理任意帧率
// 标准 FFmpeg 命令
ffmpeg -framerate 120 -i %04d.jpeg -c:v libx264 output.mp4
// Remotion 内部使用 FFmpeg 编码
// 完全支持高帧率
对比表
| 特性 | 传统屏幕录制 | Remotion 渲染 |
|------|------------|-------------|
| 帧率 | 受屏幕刷新率限制(≤60Hz) | 无限制,可设置任意 fps |
| 质量 | 受实时性能影响 | 每帧独立渲染,质量稳定 |
| 控制 | 实时播放,难以控制 | 精确控制每帧 |
| 丢帧 | 可能丢帧 | 不丢帧 |
| 复杂动画 | 可能卡顿 | 每帧有足够时间渲染 |
| 渲染时间 | 实时(1:1) | 可慢速渲染(无时间压力) |
| 可暂停 | 否 | 是(每帧独立) |
| 可并发 | 否 | 是(多页面并发渲染) |
常见帧率用途
标准帧率
// 电影:24 fps
fps: 24
// PAL 电视:25 fps
fps: 25
// NTSC 电视:29.97 fps
fps: 29.97
// 标准视频:30 fps
fps: 30
// 高帧率视频:60 fps
fps: 60
// 超高帧率:120 fps
fps: 120
// 极限帧率:240+ fps
fps: 240
使用场景
// 1. 电影感视频
<Composition fps={24} durationInFrames={2160}> // 90 秒
// 2. 教程视频(清晰)
<Composition fps={30} durationInFrames={900}
// 3. 游戏录像(流畅)
<Composition fps={60} durationInFrames={1800}
// 4. 慢动作(后期降速)
<Composition fps={120} durationInFrames={3600}
// 渲染后可以降速播放:120fps → 30fps 播放 = 0.25x 慢动作
// 5. 超慢动作
<Composition fps={240} durationInFrames={7200}
// 240fps → 30fps 播放 = 0.125x 慢动作
验证方法
方法 1: 渲染并检查帧数
# 创建 120 fps 的视频
npx remotion render test-120fps output.mp4
# 使用 FFmpeg 检查
ffprobe output.mp4
# 输出应该包含:
# fps: 120
# frames: 1200 (对于 10 秒视频)
方法 2: 检查生成的帧数
// 在渲染时使用 onFrameUpdate 回调
await renderMedia({
composition,
serveUrl: bundle,
onProgress: ({ renderedFrames, encodedFrames }) => {
console.log(`已渲染: ${renderedFrames} 帧`);
},
});
方法 3: 查看输出文件
# 如果渲染为图像序列
ls -la output/
# 输出示例:
# element-0000.jpeg
# element-0001.jpeg
# ...
# element-1199.jpeg
# 共 1200 个文件 = 120fps × 10秒
结论
Remotion 完全不受浏览器帧率限制,原因:
-
非实时渲染:每帧独立控制,不依赖浏览器刷新
-
虚拟时间线:Remotion 维护自己的时间系统
-
逐帧截图:使用 Puppeteer 精确控制每帧
-
FFmpeg 编码:支持任意帧率的视频编码
你可以安全地使用:
-
✅ 24 fps(电影)
-
✅ 30 fps(标准视频)
-
✅ 60 fps(高帧率)
-
✅ 120 fps(超高帧率)
-
✅ 240 fps+(极限帧率)
唯一的限制是:
-
⏱️ 渲染时间(性能)
-
💾 内存使用
-
💿 磁盘空间
但这些是资源限制,而不是技术限制。
资料
GitHub 个人年度总结: https://github.com/remotion-dev/github-unwrapped