比特之外

Remotion 教程 03:Composition、Sequence、spring 搭一条可控的视频时间轴

前两篇解决了两个基础问题:

  1. Composition 是视频入口。
  2. 主动画应该由 frame 推导出来。

这一篇继续往工程结构走。一个视频不应该越写越像一个巨大的 React 组件。你需要把“视频入口”“时间段”“局部动画”拆清楚。

可以先记住这条分工:

Composition 决定视频入口
Sequence 决定内容什么时候出现
spring 决定出现过程怎么运动
interpolate 把进度转成具体样式

为什么要组织时间轴

刚开始学 Remotion 时,把所有内容写在一个组件里很正常。但视频稍微复杂一点,就会出现几个问题:

  • 标题、字幕、素材、图表混在一起
  • 每段内容从第几帧开始不清楚
  • 动画延迟散落在很多数字里
  • 想复用某段动画时,需要复制一大段代码

所以 Remotion 项目通常会形成三个层次:

Composition:视频入口和参数
Scene:一段画面或一个章节
Component:标题、字幕、卡片、图表等局部组件

这不是为了抽象而抽象,而是为了让时间轴可读、可改、可复用。

Composition:注册视频入口

Composition 不负责画面细节。它负责告诉 Remotion:这个视频入口叫什么、由哪个组件渲染、尺寸和帧率是多少。

<Composition
  id="TimelineDemo"
  component={TimelineDemo}
  durationInFrames={180}
  fps={30}
  width={1920}
  height={1080}
/>

这段配置表示:

  • 视频入口叫 TimelineDemo
  • 画面由 TimelineDemo 组件渲染
  • 总长度是 180 帧
  • 帧率是 30fps
  • 输出尺寸是 1920x1080

180 帧除以 30fps,就是 6 秒。

Sequence:安排内容出现时间

视频不是所有内容同时出现。标题可能先出现,正文晚一点出现,素材再接着切入。

Sequence 用来安排一段内容在时间轴上的位置:

<Sequence from={60}>
  <Title />
</Sequence>

from={60} 表示这段内容从第 60 帧开始。

如果当前视频是 30fps,第 60 帧就是第 2 秒:

60 / 30 = 2 秒

注意:from={2} 不是 2 秒,而是第 2 帧。想表达 2 秒,应该写成:

<Sequence from={2 * fps}>

Sequence 的局部时间

Sequence 还有一个很重要的特点:它会让子组件更容易复用。

假设你写:

<Sequence from={60}>
  <Title />
</Sequence>

当全局时间走到第 60 帧时,Title 组件内部看到的 useCurrentFrame() 会从自己的局部时间开始计算。

这意味着 Title 不需要知道自己被放在全局第几秒。它只需要关心“我出现后的第 0 帧、第 10 帧、第 20 帧应该怎么动”。

spring:控制进入过程

Sequence 决定内容从什么时候开始,spring() 决定它出现时怎么运动。

const progress = spring({
  frame,
  fps,
  config: { damping: 90 },
});

这里的 frame 可以是子组件内部的局部帧号。spring() 会根据帧号生成动画进度。

然后你可以把进度映射成透明度、位移、缩放等样式:

const y = interpolate(progress, [0, 1], [40, 0]);
const opacity = interpolate(progress, [0, 1], [0, 1]);

完整最小示例

下面是一个 6 秒视频:标题先出现,卡片在第 2 秒出现,底部说明在第 4 秒出现。

import React from "react";
import {
  AbsoluteFill,
  Composition,
  Sequence,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
 
const Title: React.FC<{ text: string }> = ({ text }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
 
  const progress = spring({
    frame,
    fps,
    config: { damping: 90 },
  });
 
  const y = interpolate(progress, [0, 1], [40, 0]);
  const opacity = interpolate(progress, [0, 1], [0, 1]);
 
  return (
    <h1
      style={{
        margin: 0,
        fontSize: 76,
        opacity,
        transform: `translateY(${y}px)`,
      }}
    >
      {text}
    </h1>
  );
};
 
const Card: React.FC<{ label: string }> = ({ label }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
 
  const progress = spring({ frame, fps, config: { damping: 80 } });
  const scale = interpolate(progress, [0, 1], [0.88, 1]);
 
  return (
    <div
      style={{
        marginTop: 96,
        width: 520,
        padding: 40,
        borderRadius: 8,
        backgroundColor: "#2563eb",
        color: "white",
        fontSize: 42,
        fontWeight: 800,
        transform: `scale(${scale})`,
      }}
    >
      {label}
    </div>
  );
};
 
export const TimelineDemo: React.FC = () => {
  const { fps } = useVideoConfig();
 
  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#f8fafc",
        padding: 96,
        fontFamily: "Inter, sans-serif",
      }}
    >
      <Sequence>
        <Title text="搭一条可控的视频时间轴" />
      </Sequence>
 
      <Sequence from={2 * fps}>
        <Card label="第 2 秒出现的内容" />
      </Sequence>
 
      <Sequence from={4 * fps}>
        <p style={{ marginTop: 80, fontSize: 34 }}>
          Sequence 负责位置,spring 负责出现过程。
        </p>
      </Sequence>
    </AbsoluteFill>
  );
};
 
export const RemotionRoot = () => {
  return (
    <Composition
      id="TimelineDemo"
      component={TimelineDemo}
      durationInFrames={180}
      fps={30}
      width={1920}
      height={1080}
    />
  );
};

这个例子里有三层结构:

  • RemotionRoot 注册视频入口
  • TimelineDemo 编排整条时间轴
  • TitleCard 负责局部画面

新手容易踩的坑

把秒和帧混在一起

from={2} 是第 2 帧,不是第 2 秒。30fps 下,第 2 秒应该写:

<Sequence from={2 * fps}>

把所有动画都写在一个组件里

短 demo 可以这样写。真实模板里,标题、字幕、素材、图表应该拆成组件,否则很快会难以维护。

忘记 clamp

如果 interpolate() 用在不希望继续外推的地方,建议写清楚:

interpolate(frame, [0, 30], [0, 1], {
  extrapolateLeft: "clamp",
  extrapolateRight: "clamp",
});

示例项目位置

这篇文章对应示例项目里的时间轴基础内容:

Composition: LessonCoreBasics
Composition: LessonAnimationMethod
文件: src/lessons/CoreBasics.tsx
文件: src/lessons/AnimationMethod.tsx

运行示例项目后,可以尝试:

  1. 把动画开始时间从 frame - 30 改成 frame - 60
  2. 把位移距离从 900 改成 500。
  3. 新增一个 Sequence,让说明文字晚 30 帧出现。

做完这三个练习,你会更清楚:视频时间轴不是自动播放出来的,而是被你一帧一帧安排出来的。

下一篇开始进入真实视频素材:图片、视频、音频、转场和运动模糊。