视频动画为什么要由 frame 决定
上一篇我们跑起了第一个 Remotion Composition,也看到了 CoreBasics.tsx 里用 useCurrentFrame() 和 interpolate() 做透明度变化。这篇继续往前走:真正的视频动画应该怎么写?
对应的代码文件是:
src/lessons/AnimationMethod.tsx
对应的 Composition 是:
LessonAnimationMethod
这一节的目标很明确:让你理解 frame -> spring -> interpolate -> style 这条动画计算路径。
网页动画和视频动画不一样
在网页里,你可能会这样写动画:
.card {
transition: transform 0.3s ease;
}
这对网页交互很自然。用户 hover、点击、滑动,浏览器按真实时间播放动画。
但 Remotion 渲染的是视频。它关心的不是"浏览器经过了多少毫秒",而是:
第 0 帧是什么画面?
第 1 帧是什么画面?
第 2 帧是什么画面?
所以 Remotion 里的主动画最好都能从 frame 推导出来。
拆解 AnimationMethod.tsx
核心代码是这几行:
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - 30,
fps,
config: { damping: 90 },
});
const x = interpolate(progress, [0, 1], [0, 900]);
它分成三步。
第一步:拿到当前帧
const frame = useCurrentFrame();
useCurrentFrame() 返回当前帧号。Remotion 渲染第 0 帧时,frame 是 0;渲染第 90 帧时,frame 是 90。
这就是确定性的来源。只要输入数据和 frame 一样,这一帧算出来的结果就一样。
第二步:用 spring 生成动画进度
const progress = spring({
frame: frame - 30,
fps,
config: { damping: 90 },
});
spring() 会生成一个接近 0 到 1 的动画进度。它不像 CSS transition 那样依赖真实时间,而是依赖 frame 和 fps。
你可以把 progress 理解为"动画完成了多少":
progress = 0 动画刚开始
progress = 0.25 动画完成 25%
progress = 0.5 动画完成 50%
progress = 1 动画基本完成
它本身不是像素,也不是透明度,而是一个中间值。后面还需要把它转换成真正能用于画面的数值。
frame - 30 表示动画延迟 30 帧开始。如果当前是第 20 帧,传给 spring 的值是 -10,动画还没真正开始。如果当前是第 60 帧,传给 spring 的值是 30,动画已经推进了一段。
换成表格会更直观:
当前 frame = 0 -> spring 接收到 -30,动画还没开始
当前 frame = 30 -> spring 接收到 0,动画刚开始
当前 frame = 60 -> spring 接收到 30,动画已经进行了一段
fps 很重要。30fps 和 60fps 下,同样的帧数代表的真实时长不同。spring() 需要知道 fps,才能保持合理的动画节奏。
damping 是阻尼。阻尼越大,回弹越小;阻尼越小,弹性越明显。
第三步:把进度映射成具体元素
const x = interpolate(progress, [0, 1], [0, 900]);
progress 只是一个抽象进度。画面真正需要的是像素、透明度、缩放、旋转角度等具体数值。
这行代码表示:
- 当
progress是 0,x是 0。 - 当
progress是 1,x是 900。 - 中间值按比例计算。
也可以这样理解:
动画完成 0% -> x = 0px
动画完成 25% -> x = 225px
动画完成 50% -> x = 450px
动画完成 100% -> x = 900px
然后把它放进样式(横向位移):
transform: `translateX(${x}px)`,
注意,这里没有加 CSS transition。每一帧的 x 都由 Remotion 算出来。
为什么这比真实时间动画更稳定
想象你要批量生成 100 条视频。如果动画依赖真实时间、浏览器状态或用户交互,渲染过程就可能受环境影响。
frame-based 动画不会这样。它像一个纯函数:
frame = 60
progress = spring(...)
x = interpolate(...)
画面 = translateX(x)
只要第 60 帧重新渲染,结果就应该一致。
常见误解:把网页动画写法搬进视频
新手最容易写成这样:
return <div className="card">Hello</div>;
再在 CSS 里写:
.card {
transition: transform 0.3s ease;
}
这段代码在网页里没有问题,但在 Remotion 视频里不是理想写法。因为它没有明确告诉 Remotion,第 0 帧、第 15 帧、第 30 帧分别是什么状态。
更适合视频的写法是把状态直接写在 style 里:
const frame = useCurrentFrame();
const y = interpolate(frame, [0, 30], [40, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return <div style={{ transform: `translateY(${y}px)` }}>Hello</div>;
这样每一帧的 y 都能被明确计算出来。你拖到第 10 帧、第 20 帧、第 30 帧,都能得到稳定的画面。
和第一篇的关系
第一篇讲的是项目结构和 Composition,你知道了视频入口从哪里来。
这一篇讲的是动画计算,你知道了画面状态怎么从 frame 推导出来。
学习顺序可以这样理解:
Root.tsx注册视频。CoreBasics.tsx认识帧和时间轴。AnimationMethod.tsx把 frame 变成动画。- 后面再单独讨论第三方动画库的边界。
动手练习
打开 LessonAnimationMethod,把横向位移动画改成缩放动画:
transform: `scale(${progress})`,
然后再试试旋转:
transform: `rotate(${x / 10}deg)`,
练习时要保留一个原则:动画数值仍然来自 frame,而不是 CSS transition。
下一篇我们会把 Composition、Sequence 和 spring 放在一起,讲如何搭一条更清晰的视频时间轴。