Remotion - 完整学习教程

Remotion - 完整学习教程

教程级别: 从零到一 预计学习时间: 8-12 小时 前置知识: React 基础(Hooks、组件、props)、CSS 布局、JavaScript/TypeScript 基础、Node.js 环境使用

环境搭建指南

系统要求

  • 操作系统: macOS、Windows、Linux 均可
  • Node.js: v18.0+(推荐 v20 LTS)
  • 包管理器: npm(Node.js 自带)或 bun
  • 磁盘空间: 至少 1GB(用于 Chrome 自动下载和项目依赖)
  • 浏览器: Chrome/Chromium(Remotion 自动下载,无需手动安装)

安装步骤

# 确认 Node.js 版本(需要 v18+)
node -v

# 使用 npx 创建 Remotion 项目(推荐 Hello World 模板)
npx create-video@latest my-first-video

# 进入项目目录
cd my-first-video

# 安装依赖
npm install

创建过程中,CLI 会提示选择模板。推荐首次使用选择 Hello World 模板。

验证安装

# 启动 Remotion Studio(开发预览环境)
npx remotion studio

# 预期:浏览器自动打开 http://localhost:3000
# 你将看到 Remotion Studio 界面,左侧是 Composition 列表,右侧是视频预览区

执行结果:

Remotion Studio started on http://localhost:3000
You can now view my-first-video in the browser.

如果看到 Remotion Studio 界面并在预览区看到一个带文字的动画视频,说明环境搭建成功。


第一部分:入门篇

1.1 核心概念:帧号(Frame)与 Composition

概念讲解:

Remotion 的核心理念非常简洁:给 React 组件一个帧号(frame number),组件根据帧号渲染出该帧的画面。 视频就是"随时间变化的图像序列"——当帧号从 0 递增到最后一帧时,每帧渲染的内容连起来就形成了动画。

Composition(组合)是 React 组件与视频元数据的结合体,它告诉 Remotion:这个组件应该以什么分辨率、什么帧率、渲染多长时间。

关键 Hooks: - useCurrentFrame() — 获取当前帧号 - useVideoConfig() — 获取视频配置(宽、高、帧率、总帧数)

代码示例:

// src/HelloFrame.tsx
import React from "react";
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";

export const HelloFrame: React.FC = () => {
  // 获取当前帧号(从 0 开始)
  const frame = useCurrentFrame();
  // 获取视频配置
  const { fps, durationInFrames, width, height } = useVideoConfig();

  // 计算当前时间(秒)
  const currentTime = (frame / fps).toFixed(2);
  // 计算进度百分比
  const progress = ((frame / (durationInFrames - 1)) * 100).toFixed(1);

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0b0b0b",
        color: "white",
        justifyContent: "center",
        alignItems: "center",
        fontFamily: "sans-serif",
      }}
    >
      <div style={{ fontSize: 60, marginBottom: 20 }}>
        帧号: {frame}
      </div>
      <div style={{ fontSize: 30, color: "#aaa" }}>
        {width}x{height}px | {currentTime}s / {(durationInFrames / fps).toFixed(2)}s
      </div>
      <div style={{ fontSize: 24, color: "#666", marginTop: 10 }}>
        进度: {progress}%
      </div>
    </AbsoluteFill>
  );
};

src/Root.tsx 中注册这个 Composition:

// src/Root.tsx
import React from "react";
import { Composition } from "remotion";
import { HelloFrame } from "./HelloFrame";

export const RemotionRoot: React.FC = () => {
  return (
    <>
      <Composition
        id="HelloFrame"
        component={HelloFrame}
        durationInFrames={150}
        fps={30}
        width={1920}
        height={1080}
      />
    </>
  );
};

执行结果:

在 Remotion Studio 中预览:
- 视频显示黑色背景上的白色文字
- 文字内容随帧号变化:帧号从 0 递增到 149
- 进度从 0.0% 变化到 100.0%
- 总时长:150帧 / 30fps = 5秒

练习题: 1. 修改 durationInFrames 为 300,观察视频时长变化 2. 将 fps 改为 60,观察视频播放速度变化(注意:帧数不变但播放速度会加快)


1.2 插值动画(Interpolation)

概念讲解:

上一节我们学会了获取帧号,但帧号只是一个线性递增的整数。interpolate() 函数的核心作用是将帧号映射到任意范围的值,从而驱动视觉属性的变化(如位置、透明度、颜色等)。

interpolate(inputValue, inputRange, outputRange, options) 的参数说明: - inputValue:输入值(通常是帧号) - inputRange:输入值范围,如 [0, 30] - outputRange:输出值范围,如 [0, 1] - options(可选):控制超出范围时的行为(extrapolateLeftextrapolateRight

代码示例:

// src/FadeInOut.tsx
import React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";

export const FadeInOut: React.FC = () => {
  const frame = useCurrentFrame();

  // 在帧 0-30 之间:透明度从 0 渐变到 1(淡入)
  const fadeIn = interpolate(frame, [0, 30], [0, 1], {
    extrapolateRight: "clamp",
  });

  // 在帧 90-120 之间:透明度从 1 渐变到 0(淡出)
  const fadeOut = interpolate(frame, [90, 120], [1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // 取两者中较小的值,确保淡出生效
  const opacity = Math.min(fadeIn, fadeOut);

  // 在帧 10-50 之间:从下方滑入(Y 轴偏移从 100 到 0)
  const translateY = interpolate(frame, [10, 50], [100, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // 在帧 0-60 之间:缩放从 0.8 到 1.0
  const scale = interpolate(frame, [0, 60], [0.8, 1.0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#1a1a2e",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <div
        style={{
          opacity,
          transform: `translateY(${translateY}px) scale(${scale})`,
          fontSize: 80,
          color: "white",
          fontFamily: "sans-serif",
          textShadow: "0 4px 20px rgba(0,0,0,0.5)",
        }}
      >
        Hello, Remotion!
      </div>
    </AbsoluteFill>
  );
};

别忘了在 Root.tsx 中注册:

// 在 RemotionRoot 的 <>...</> 中添加:
<Composition
  id="FadeInOut"
  component={FadeInOut}
  durationInFrames={150}
  fps={30}
  width={1920}
  height={1080}
/>

执行结果:

在 Remotion Studio 中预览:
- 0-30帧:文字从完全透明渐变到完全可见(淡入)
- 10-50帧:文字同时从下方滑入到中央
- 30-90帧:文字保持完全可见
- 90-120帧:文字渐变到完全透明(淡出)
- 0-60帧:文字从 0.8x 缩放到 1.0x

练习题: 1. 实现一个从左侧滑入的效果(提示:使用 translateX 代替 translateY) 2. 让文字颜色在帧 0-90 之间从蓝色渐变为红色(提示:使用 interpolateColors()


1.3 Spring 弹簧动画

概念讲解:

interpolate() 创建的是线性插值动画,运动速度恒定。但自然界的运动通常有加速和减速——spring() 函数模拟物理弹簧运动,创建更自然的弹性效果。

Spring 的关键参数: - fps:视频帧率(必填) - frame:弹簧动画的"本地帧号"(通常用 frame - delay 实现延迟启动) - config.damping:阻尼系数,越大回弹越小(默认 10) - config.stiffness:弹簧刚度,越大弹速越快(默认 100) - config.mass:质量,越大运动越迟缓(默认 1)

代码示例:

// src/SpringDemo.tsx
import React from "react";
import {
  AbsoluteFill,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

export const SpringDemo: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 第一个弹簧动画:从帧 0 开始
  const scale1 = spring({
    fps,
    frame,
    config: { damping: 12, stiffness: 200, mass: 0.5 },
  });

  // 第二个弹簧动画:从帧 15 开始(延迟 15 帧)
  const scale2 = spring({
    fps,
    frame: frame - 15,
    config: { damping: 8, stiffness: 100, mass: 1 },
  });

  // 第三个弹簧动画:从帧 30 开始(延迟 30 帧)
  const scale3 = spring({
    fps,
    frame: frame - 30,
    config: { damping: 15, stiffness: 300, mass: 0.3 },
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#16213e",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "row",
        gap: 40,
      }}
    >
      {[scale1, scale2, scale3].map((scale, index) => (
        <div
          key={index}
          style={{
            width: 150,
            height: 150,
            backgroundColor: ["#e94560", "#0f3460", "#533483"][index],
            borderRadius: 20,
            transform: `scale(${scale})`,
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
            color: "white",
            fontSize: 30,
            fontFamily: "sans-serif",
          }}
        >
          {index + 1}
        </div>
      ))}
    </AbsoluteFill>
  );
};

执行结果:

在 Remotion Studio 中预览:
- 方块 1 从帧 0 开始弹性缩放(快速弹入,轻微回弹后稳定)
- 方块 2 从帧 15 开始弹性缩放(较慢弹入,更明显的回弹)
- 方块 3 从帧 30 开始弹性缩放(快速弹入,几乎没有回弹)
- 三个方块依次弹入,形成错落有致的入场效果

练习题: 1. 调整 damping 为 4,观察回弹效果的变化(会更有"弹力"感) 2. 使用 spring 实现 Y 轴位移,让方块从底部弹入到中央


第二部分:进阶篇

2.1 Sequence 和 Series:时间线编排

概念讲解:

真实视频通常由多个场景组成。<Sequence> 用于定义视频中的一个时间片段,<Series> 用于按顺序排列多个片段。它们的区别: - <Sequence>:指定绝对起始帧(from)和持续帧数(durationInFrames),支持重叠 - <Series>:自动按顺序排列,不需要手动计算起始帧

代码示例:

// src/MultiScene.tsx
import React from "react";
import {
  AbsoluteFill,
  Sequence,
  Series,
  interpolate,
  useCurrentFrame,
} from "remotion";

// 场景 1:标题页
const TitleScene: React.FC<{ title: string; color: string }> = ({
  title,
  color,
}) => {
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [0, 10], [0, 1], {
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: color,
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <div style={{ opacity, fontSize: 80, color: "white", fontFamily: "sans-serif" }}>
        {title}
      </div>
    </AbsoluteFill>
  );
};

// 场景 2:内容页
const ContentScene: React.FC<{ items: string[] }> = ({ items }) => {
  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#2d3436",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "column",
        gap: 20,
      }}
    >
      {items.map((item, index) => (
        <Series key={index}>
          <Series.Sequence durationInFrames={10}>
            <FadeInItem text={item} delay={0} />
          </Series.Sequence>
        </Series>
      ))}
    </AbsoluteFill>
  );
};

// 列表项淡入组件
const FadeInItem: React.FC<{ text: string; delay: number }> = ({
  text,
  delay,
}) => {
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [0 + delay, 10 + delay], [0, 1], {
    extrapolateRight: "clamp",
  });
  const translateY = interpolate(frame, [0 + delay, 10 + delay], [20, 0], {
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        opacity,
        transform: `translateY(${translateY}px)`,
        fontSize: 40,
        color: "white",
        fontFamily: "sans-serif",
        padding: "10px 30px",
        backgroundColor: "#636e72",
        borderRadius: 10,
      }}
    >
      {text}
    </div>
  );
};

// 主视频组件:使用 Series 编排多个场景
export const MultiScene: React.FC = () => {
  return (
    <AbsoluteFill>
      <Series>
        {/* 场景 1:标题 - 持续 60 帧(2 秒) */}
        <Series.Sequence durationInFrames={60}>
          <TitleScene title="欢迎观看" color="#e17055" />
        </Series.Sequence>

        {/* 场景 2:内容 - 持续 90 帧(3 秒) */}
        <Series.Sequence durationInFrames={90}>
          <TitleScene title="功能介绍" color="#00b894" />
        </Series.Sequence>

        {/* 场景 3:结尾 - 持续 60 帧(2 秒) */}
        <Series.Sequence durationInFrames={60}>
          <TitleScene title="谢谢观看" color="#6c5ce7" />
        </Series.Sequence>
      </Series>
    </AbsoluteFill>
  );
};

Root.tsx 中注册(注意总帧数 = 60 + 90 + 60 = 210):

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

执行结果:

在 Remotion Studio 中预览:
- 0-2秒:橙色背景,显示"欢迎观看"
- 2-5秒:绿色背景,显示"功能介绍"
- 5-7秒:紫色背景,显示"谢谢观看"
- 每个场景切换时文字有淡入动画

注意事项: - <Series> 自动计算每个片段的起始帧,不要和 from 属性混用 - 如果需要场景重叠(如转场效果),使用 <Sequence from={...}> 手动控制 - 总帧数必须 ≥ 所有 Series.Sequence 的 durationInFrames 之和

练习题: 1. 在场景切换之间添加 10 帧的淡入淡出转场(提示:使用 <Sequence> 配合 from 负值) 2. 添加第四个场景,展示一个列表


2.2 参数化视频与 Props 传递

概念讲解:

参数化是 Remotion 的杀手级特性之一。通过向 Composition 传递 defaultProps,可以为不同的数据生成不同的视频。在渲染时,可以通过 inputProps 覆盖默认值,实现批量生成个性化视频。

代码示例:

// src/PersonalizedVideo.tsx
import React from "react";
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

// 定义 props 类型
type PersonalizedVideoProps = {
  userName: string;
  score: number;
  achievements: string[];
  year: number;
};

export const PersonalizedVideo: React.FC<PersonalizedVideoProps> = ({
  userName,
  score,
  achievements,
  year,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 用户名弹性入场
  const nameScale = spring({ fps, frame, config: { damping: 12 } });

  // 分数从 0 动画到目标值
  const displayedScore = Math.round(
    interpolate(frame, [30, 90], [0, score], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
    })
  );

  // 成就列表逐个显示
  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0a0a23",
        color: "white",
        fontFamily: "sans-serif",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "column",
        gap: 30,
      }}
    >
      {/* 年份标签 */}
      <div style={{ fontSize: 24, color: "#888" }}>{year} 年度回顾</div>

      {/* 用户名 */}
      <div style={{ fontSize: 70, transform: `scale(${nameScale})` }}>
        {userName}
      </div>

      {/* 分数 */}
      <div style={{ fontSize: 50, color: "#ffd700" }}>
        得分: {displayedScore}
      </div>

      {/* 成就列表 */}
      <div style={{ display: "flex", gap: 15, flexDirection: "column" }}>
        {achievements.map((achievement, index) => {
          const itemScale = spring({
            fps,
            frame: frame - 60 - index * 10,
            config: { damping: 10 },
          });
          return (
            <div
              key={index}
              style={{
                fontSize: 28,
                padding: "8px 24px",
                backgroundColor: "#1a1a3e",
                borderRadius: 8,
                transform: `scale(${itemScale})`,
                opacity: itemScale,
              }}
            >
              {achievement}
            </div>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

Root.tsx 中注册并传递默认 props:

import { PersonalizedVideo } from "./PersonalizedVideo";

// 在 RemotionRoot 中添加:
<Composition
  id="PersonalizedVideo"
  component={PersonalizedVideo}
  durationInFrames={180}
  fps={30}
  width={1920}
  height={1080}
  defaultProps={{
    userName: "张三",
    score: 12800,
    achievements: ["代码大师", "团队之星", "创新先锋"],
    year: 2026,
  }}
/>

渲染时覆盖 props:

# 使用 CLI 渲染,通过 --props 传递自定义数据
npx remotion render PersonalizedVideo out.mp4 \
  --props '{"userName":"李四","score":9500,"achievements":["新人奖","进步最快"],"year":2026}'

执行结果:

在 Remotion Studio 中预览(使用 defaultProps):
- 显示"2026 年度回顾"
- "张三"弹性入场
- 分数从 0 动画增长到 12800
- 成就列表逐个弹入:代码大师、团队之星、创新先锋

使用 --props 渲染时:
- 显示对应的数据(李四、9500 分等)

注意事项: - defaultProps 中的值必须是 JSON 可序列化的(不能包含函数、Date 对象等) - --props 参数必须是有效的 JSON 字符串,注意 shell 引号转义 - 批量渲染时,建议编写 Node.js 脚本调用 renderMedia() API

练习题: 1. 添加一个 avatarUrl prop,在视频中显示用户头像(提示:使用 <Img> 组件) 2. 编写一个 Node.js 脚本,循环渲染 10 个不同用户的个性化视频


2.3 音频处理

概念讲解:

Remotion 支持在视频中添加音频轨道。使用 <Audio> 组件可以嵌入音频文件,支持音量控制和淡入淡出效果。

代码示例:

// src/AudioDemo.tsx
import React from "react";
import {
  AbsoluteFill,
  Audio,
  interpolate,
  Sequence,
  useCurrentFrame,
  useVideoConfig,
  staticFile,
} from "remotion";

export const AudioDemo: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 背景音乐音量:前 1 秒淡入,最后 1 秒淡出
  const bgVolume = interpolate(
    frame,
    [0, 30, 270, 300],
    [0, 0.3, 0.3, 0],
    { extrapolateRight: "clamp" }
  );

  // 将音频文件放在 public/ 目录下
  return (
    <AbsoluteFill style={{ backgroundColor: "#111" }}>
      {/* 背景音乐:全程播放 */}
      <Audio src={staticFile("background.mp3")} volume={bgVolume} />

      {/* 音效:在第 30 帧播放 */}
      <Sequence from={30} durationInFrames={60}>
        <Audio src={staticFile("ding.mp3")} volume={0.5} />
      </Sequence>

      {/* 视觉内容 */}
      <AbsoluteFill
        style={{
          justifyContent: "center",
          alignItems: "center",
          color: "white",
          fontSize: 60,
          fontFamily: "sans-serif",
        }}
      >
        <div>音频演示</div>
      </AbsoluteFill>
    </AbsoluteFill>
  );
};

注意事项: - 音频文件必须放在项目的 public/ 目录下,通过 staticFile() 引用 - 也支持远程 URL:src="https://example.com/audio.mp3" - <Audio> 不支持波形可视化,如需可视化需要使用 Web Audio API - 渲染时确保音频文件可访问,否则会报 "Media playback error"

练习题: 1. 实现一个"打字机"效果,每输入一个字符时播放一次音效


第三部分:高级篇

3.1 服务端渲染(SSR)

概念讲解:

开发时使用 Remotion Studio 预览,生产环境需要将 React 组件渲染为真实的 MP4 文件。Remotion 提供了 @remotion/renderer 包用于 Node.js 环境(`backend)的服务端渲染。

渲染流程:打包项目 → 启动 Headless Chrome → 逐帧截图 → FFmpeg 合成视频。

代码示例:

// render.ts(放在项目根目录)
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";

async function render() {
  // 第 1 步:打包 Remotion 项目
  const bundleLocation = await bundle({
    entryPoint: path.resolve("./src/index.ts"),
    webpackOverride: (config) => config,
  });

  // 第 2 步:获取 Composition 配置
  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: "PersonalizedVideo",
    // 可以在这里覆盖 inputProps
    inputProps: {
      userName: "王五",
      score: 15000,
      achievements: ["MVP", "最佳贡献者"],
      year: 2026,
    },
  });

  // 第 3 步:渲染视频
  await renderMedia({
    composition,
    serveUrl: bundleLocation,
    codec: "h264",
    outputLocation: "out/wangwu-2026.mp4",
    // 并发数(影响渲染速度)
    concurrency: 4,
    // 传入自定义 props
    inputProps: {
      userName: "王五",
      score: 15000,
      achievements: ["MVP", "最佳贡献者"],
      year: 2026,
    },
  });

  console.log("渲染完成!");
}

render().catch(console.error);

运行渲染:

# 安装渲染依赖
npm install --save-dev @remotion/bundler @remotion/renderer

# 执行渲染脚本
npx tsx render.ts

执行结果:

Bundling... done in 3.2s
Rendering frames... ████████████████████████ 100%
Encoding video... done
Audio encoding... done
渲染完成!
输出文件:out/wangwu-2026.mp4

注意事项: - 首次运行会自动下载 Chrome 和 FFmpeg,可能需要几分钟 - concurrency 参数控制同时渲染的帧数,过高可能导致内存不足 - 服务器渲染需要足够的内存(建议 2GB+)


3.2 性能优化

优化策略 1:使用 JPEG 格式加速渲染

# 默认使用 PNG 格式,切换到 JPEG 可以显著加速
npx remotion render MyComp out.mp4 --image-format=jpeg

优化策略 2:使用 useMemo 缓存昂贵计算

import React, { useMemo } from "react";
import { useCurrentFrame } from "remotion";

export const OptimizedComp: React.FC = () => {
  const frame = useCurrentFrame();

  // 缓存昂贵计算,避免每帧重新计算
  const expensiveData = useMemo(() => {
    return Array.from({ length: 1000 }, (_, i) => ({
      x: Math.sin(i * 0.1 + frame * 0.05) * 100,
      y: Math.cos(i * 0.1 + frame * 0.05) * 100,
    }));
  }, [frame]);

  return (
    <div>
      {expensiveData.map((point, i) => (
        <div
          key={i}
          style={{
            position: "absolute",
            left: point.x + 960,
            top: point.y + 540,
            width: 4,
            height: 4,
            borderRadius: 2,
            backgroundColor: "white",
          }}
        />
      ))}
    </div>
  );
};

优化策略 3:使用 npx remotion benchmark 找到最优并发数

# 自动测试不同并发数,输出最优配置
npx remotion benchmark

执行结果:

Concurrency 1: 45.2s
Concurrency 2: 28.7s
Concurrency 4: 18.3s  ← 推荐
Concurrency 8: 22.1s
Concurrency 16: 35.8s

最优并发数:4(在当前硬件上)

3.3 最佳实践

  1. 保持组件纯净:每帧渲染应该是纯函数——给定相同帧号和 props,输出必须完全一致。不要使用 Date.now()Math.random() 等不确定值。

  2. 提前加载数据:如果必须在组件内获取异步数据,使用 delayRender() + continueRender() 暂停渲染直到数据就绪。最佳实践是在 Composition 外部获取数据,通过 props 传入。

  3. 避免 GPU 效果:在云端渲染时(无 GPU),CSS blur()box-shadow、WebGL 等效果会显著降低性能。考虑用预计算图片替代。

  4. 使用新的 <Video> 标签:Remotion v4 引入了新的优化视频标签,比旧的 <OffthreadVideo> 性能更好。

  5. 合理使用 --scale:如果不需要全分辨率,使用 --scale 0.5 可以将渲染速度提升约 4 倍。


第四部分:实战项目

项目需求

构建一个数据驱动的年度报告视频生成器,输入用户数据(姓名、头像、统计数据),自动生成一段 10 秒的个性化年度回顾视频。

项目需要综合运用: - 插值动画(Interpolation) - Spring 弹簧动画 - Sequence/Series 时间线编排 - 参数化视频与 Props 传递

项目设计

视频结构(10 秒 / 300 帧 / 30fps):
├── 开场(0-90帧 / 0-3秒):用户名弹入 + 背景动画
├── 数据展示(90-210帧 / 3-7秒):三个数据卡片依次出现
└── 结尾(210-300帧 / 7-10秒):总结语 + 淡出

完整实现代码

// src/AnnualReport.tsx
import React from "react";
import {
  AbsoluteFill,
  interpolate,
  interpolateColors,
  spring,
  useCurrentFrame,
  useVideoConfig,
  Sequence,
} from "remotion";

// Props 类型定义
type AnnualReportProps = {
  userName: string;
  avatarColor: string;
  stats: { label: string; value: number; unit: string }[];
  summary: string;
};

// 单个数据卡片组件
const StatCard: React.FC<{
  label: string;
  value: number;
  unit: string;
  delay: number;
  color: string;
}> = ({ label, value, unit, delay, color }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const scale = spring({
    fps,
    frame: frame - delay,
    config: { damping: 10, stiffness: 150, mass: 0.5 },
  });

  // 数值从 0 增长到目标值
  const displayedValue = Math.round(
    interpolate(frame, [delay, delay + 30], [0, value], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
    })
  );

  return (
    <div
      style={{
        backgroundColor: color,
        padding: "20px 30px",
        borderRadius: 16,
        opacity: scale,
        transform: `scale(${scale})`,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        minWidth: 200,
      }}
    >
      <div style={{ fontSize: 20, color: "rgba(255,255,255,0.8)" }}>{label}</div>
      <div style={{ fontSize: 48, color: "white", fontWeight: "bold" }}>
        {displayedValue.toLocaleString()}
      </div>
      <div style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>{unit}</div>
    </div>
  );
};

// 主组件
export const AnnualReport: React.FC<AnnualReportProps> = ({
  userName,
  avatarColor,
  stats,
  summary,
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  // 背景颜色渐变
  const bgColor = interpolateColors(
    frame,
    [0, durationInFrames],
    ["#0f0c29", "#302b63"]
  );

  // 用户名弹性入场
  const nameScale = spring({
    fps,
    frame,
    config: { damping: 12, stiffness: 200 },
  });

  // 结尾淡出
  const outroOpacity = interpolate(
    frame,
    [durationInFrames - 30, durationInFrames],
    [1, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );

  return (
    <AbsoluteFill style={{ backgroundColor: bgColor }}>
      <div
        style={{
          opacity: outroOpacity,
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          alignItems: "center",
          gap: 50,
          fontFamily: "sans-serif",
          color: "white",
        }}
      >
        {/* 开场:用户名 + 头像 */}
        <Sequence from={0} durationInFrames={90}>
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              gap: 20,
            }}
          >
            {/* 头像 */}
            <div
              style={{
                width: 120,
                height: 120,
                borderRadius: 60,
                backgroundColor: avatarColor,
                transform: `scale(${nameScale})`,
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
                fontSize: 50,
                boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
              }}
            >
              {userName.charAt(0)}
            </div>
            {/* 用户名 */}
            <div
              style={{
                fontSize: 56,
                fontWeight: "bold",
                transform: `scale(${nameScale})`,
              }}
            >
              {userName} 的 2026
            </div>
          </div>
        </Sequence>

        {/* 数据展示:三个卡片 */}
        <Sequence from={60} durationInFrames={150}>
          <div
            style={{
              display: "flex",
              gap: 30,
            }}
          >
            {stats.map((stat, index) => (
              <StatCard
                key={index}
                label={stat.label}
                value={stat.value}
                unit={stat.unit}
                delay={30 + index * 15}
                color={["#e17055", "#00b894", "#6c5ce7"][index]}
              />
            ))}
          </div>
        </Sequence>

        {/* 结尾:总结语 */}
        <Sequence from={210} durationInFrames={90}>
          <div
            style={{
              fontSize: 40,
              opacity: interpolate(frame, [210, 240], [0, 1], {
                extrapolateLeft: "clamp",
                extrapolateRight: "clamp",
              }),
              textAlign: "center",
              maxWidth: 800,
              lineHeight: 1.6,
            }}
          >
            {summary}
          </div>
        </Sequence>
      </div>
    </AbsoluteFill>
  );
};

Root.tsx 中注册:

import { AnnualReport } from "./AnnualReport";

// 在 RemotionRoot 中添加:
<Composition
  id="AnnualReport"
  component={AnnualReport}
  durationInFrames={300}
  fps={30}
  width={1920}
  height={1080}
  defaultProps={{
    userName: "张三",
    avatarColor: "#e17055",
    stats: [
      { label: "代码提交", value: 1280, unit: "次" },
      { label: "PR 合并", value: 156, unit: "个" },
      { label: "代码审查", value: 423, unit: "次" },
    ],
    summary: "这一年,你用代码改变了世界。\n继续保持,2027 会更精彩!",
  }}
/>

批量渲染脚本:

// batch-render.ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";

// 用户数据列表
const users = [
  {
    userName: "张三",
    avatarColor: "#e17055",
    stats: [
      { label: "代码提交", value: 1280, unit: "次" },
      { label: "PR 合并", value: 156, unit: "个" },
      { label: "代码审查", value: 423, unit: "次" },
    ],
    summary: "这一年,你用代码改变了世界。",
  },
  {
    userName: "李四",
    avatarColor: "#00b894",
    stats: [
      { label: "代码提交", value: 890, unit: "次" },
      { label: "PR 合并", value: 98, unit: "个" },
      { label: "代码审查", value: 267, unit: "次" },
    ],
    summary: "你的每一行代码都在创造价值。",
  },
];

async function batchRender() {
  const bundleLocation = await bundle({
    entryPoint: path.resolve("./src/index.ts"),
    webpackOverride: (config) => config,
  });

  for (const user of users) {
    const composition = await selectComposition({
      serveUrl: bundleLocation,
      id: "AnnualReport",
      inputProps: user,
    });

    const fileName = `out/${user.userName}-2026.mp4`;
    await renderMedia({
      composition,
      serveUrl: bundleLocation,
      codec: "h264",
      outputLocation: fileName,
      inputProps: user,
    });
    console.log(`✅ ${fileName} 渲染完成`);
  }
}

batchRender().catch(console.error);

代码解析

代码片段 运用的知识点 说明
spring({ fps, frame, config: {...} }) Spring 弹簧动画 用户名和头像的入场动画使用 spring 实现自然的弹性效果
interpolate(frame, [delay, delay+30], [0, value]) 插值动画 数据卡片的数值从 0 到目标值的增长动画
<Sequence from={60} durationInFrames={150}> 时间线编排 使用 Sequence 控制场景的出现时机和持续时间
interpolateColors(frame, [0, N], [color1, color2]) 颜色插值 背景颜色在整个视频中平滑渐变
defaultProps + inputProps 参数化视频 通过 props 传递用户数据,实现批量个性化渲染

扩展挑战

  1. 添加音频支持:为视频添加背景音乐,实现淡入淡出效果
  2. 添加图表可视化:使用 SVG 或 Canvas 绘制柱状图/折线图,展示月度数据趋势
  3. 云端渲染:使用 @remotion/lambda 将渲染部署到 AWS Lambda,实现按需生成

第五部分:常见问题与排查指南

常见错误及解决方案

错误信息 原因 解决方案
Could not play video/audio 媒体文件不存在或格式不支持 确保文件在 public/ 目录下,格式为 MP4/MP3/WebM
ENAMETOOLONG 渲染输出路径过长 缩短文件名或使用更短的输出路径
Timeout while rendering frame 组件渲染过慢,超过默认超时时间 使用 --timeout 增加超时时间,或优化组件性能
Target closed Headless Chrome 崩溃 减小并发数(--concurrency),检查内存是否充足
Flickering in output 帧间渲染不一致 确保组件是纯函数,不要使用 Date.now()Math.random()
No audio in output 未正确配置音频编解码器 使用 --codec h264 并确保 <Audio> 组件正确放置
Could not determine executable Chrome/FFmpeg 未找到 运行 npx remotion browser ensure 确保浏览器已下载
Version mismatch Remotion 包版本不一致 确保所有 @remotion/* 包版本号一致

调试技巧

  1. 使用 --log=verbose 定位慢帧:渲染时添加此参数,会输出每帧的渲染时间和最慢帧列表,帮助定位性能瓶颈。
npx remotion render MyComp out.mp4 --log=verbose
  1. 在 Studio 中逐步调试:使用 Remotion Studio 的时间线拖拽功能,逐帧检查动画效果。可以打开浏览器开发者工具查看 React 组件的渲染状态。

  2. 使用 console.time 测量组件性能:在组件中使用 console.time()console.timeEnd() 测量昂贵操作的耗时,在 Studio 控制台中查看输出。

export const MyComp = () => {
  const frame = useCurrentFrame();

  console.time("expensive-calc");
  const result = someExpensiveCalculation(frame);
  console.timeEnd("expensive-calc");

  return <div>{result}</div>;
};

第六部分:学习路线推荐

官方文档推荐阅读顺序

  1. The Fundamentals — 核心概念:帧号、Composition、Hooks(入门必读)
  2. Sequences — 时间线编排:场景切换和片段管理
  3. Using audio — 音频处理:背景音乐和音效
  4. Parameterized videos — 参数化渲染:批量生成个性化视频
  5. Rendering — 渲染配置:编码格式、分辨率、并发控制
  6. Server-side rendering — 服务端渲染:Node.js API 和批量渲染
  7. Lambda — 云端渲染:AWS Lambda 部署和按需生成
  8. Performance — 性能优化:benchmark、并发调优、缓存策略
  9. AI Agent Skills — AI 集成:使用 Claude Code 生成视频代码

推荐进阶资源

信息来源与版本说明