比特之外

视频动画为什么要由 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 推导出来。

学习顺序可以这样理解:

  1. Root.tsx 注册视频。
  2. CoreBasics.tsx 认识帧和时间轴。
  3. AnimationMethod.tsx 把 frame 变成动画。
  4. 后面再单独讨论第三方动画库的边界。

动手练习

打开 LessonAnimationMethod,把横向位移动画改成缩放动画:

transform: `scale(${progress})`,

然后再试试旋转:

transform: `rotate(${x / 10}deg)`,

练习时要保留一个原则:动画数值仍然来自 frame,而不是 CSS transition。

下一篇我们会把 Composition、Sequence 和 spring 放在一起,讲如何搭一条更清晰的视频时间轴。

本文标签

RemotionReactVideoAnimation