硅基俊宁.
Skills · 技能体系 ↗
← 返回文章

用 Claude Code + Remotion 复刻 RunCat 奔跑的猫帧动画

俊宁 · 2026-07-03
迁自历史博客,保留原文、署名、引用与原始日期;文中的版本、成果与性能结论不代表重新完成了当前验收。
查看原始出处 ↗ · 查看迁移清单
用 Claude Code + Remotion 复刻 RunCat 奔跑的猫帧动画封面

macOS 菜单栏那只跟着 CPU 使用率越跑越快的小猫(RunCat),大概是我用过最治愈的系统监控工具。作为一个从前端转 Agent 开发的人,我一直好奇:这种逐帧 sprite 动画,能不能用现代 React 工具链声明式地复刻出来?答案是 Remotion——一个用 React 写视频的框架。这篇文章我用 Claude Code + Remotion 把这只猫从头搭一遍,顺带把「帧动画」这件事讲透。

一句话总结

RunCat 的猫本质只有 5 张 PNG,靠「取模循环 + 定时器间隔」跑起来。Remotion 的纯函数帧模型天生适合表达这种逐帧动画:把「第几帧显示哪张图」写成一个纯函数即可。但要认清边界——Remotion 产出的是渲染好的 GIF/视频资产,不是常驻菜单栏的原生进程。想要真·菜单栏猫得靠 Tauri/Electron,Remotion 负责的是把动画本身优雅地造出来。

先扒开 RunCat:它到底怎么动

很多人以为菜单栏动画是什么黑魔法,扒完源码发现朴素得可爱。RunCat 的核心就三件事。

1. 5 帧 PNG 循环

仓库 Assets.xcassets 里躺着 cat0.png 到 cat4.png,一共 5 帧。帧循环逻辑一行搞定:

index = (index + 1) % frames.count   // 4 之后回到 0,无限循环
statusItem.button?.image = frames[index]

这个 % frames.count(取模)就是所有 sprite 循环动画的灵魂——记住它,等下 Remotion 里还是它。

2. CPU 使用率决定「跑多快」

猫跑多快,取决于定时器的触发间隔。RunCat 的速度公式是:

interval = 0.2 / max(1.0, min(20.0, usage / 5.0))

拆开看:

所以 CPU 越高 → 倍率越大 → 间隔越短 → 换帧越快 → 猫跑得越急。空闲时 0.2 秒一帧,满载时能到 0.01 秒一帧。就这么个反比关系,体感却极其直观。

3. 定时器驱动

Timer(timeInterval: self.interval, repeats: true) { self?.next() }

每隔 interval 触发一次 next(),next() 里就是上面那句取模换帧。这是典型的「有状态命令式」动画:一个 index 变量,定时 +1。

记住这个「命令式、有状态」的特征——因为 Remotion 的模型跟它恰好相反,这是本文最值得玩味的地方。

为什么用 Remotion 做帧动画

Remotion 的核心信条只有一句:每一帧都是 useCurrentFrame() 的纯函数。

官方原话是——把你的组件想象成一个「把帧号转换成图像」的函数。每一帧独立渲染,不依赖上一帧的状态。这意味着 CSS 动画、setInterval、requestAnimationFrame 那套在 Remotion 里几乎全部失效,你必须重新用「帧号 → 画面」的思路建模。

对比一下就懂了:

RunCat(命令式)Remotion(声明式)
状态维护一个 index 变量无状态,只读 frame
换帧定时器 index++Math.floor(frame / n) % 5
时间真实时钟(Timer)逻辑帧号(useCurrentFrame)
产物实时菜单栏画面渲染成 GIF/视频

看出来了吗?RunCat 那句 (index + 1) % 5 的「取模」精神,在 Remotion 里变成了用当前帧号直接算出该显示第几张 sprite——不需要状态,因为帧号本身就是「时间的坐标」。这就是纯函数帧模型的优雅之处。

动手:Claude Code + Remotion 复刻奔跑的猫

第一步:让 Claude Code 起项目

我的做法是直接把需求丢给 Claude Code,让它搭脚手架:

初始化一个 Remotion 项目,创建一个叫 RunningCat 的 Composition:
- 144x144,30fps
- 加载 public/cat/ 下的 0.png~4.png 五帧 sprite
- 根据传入的 load(模拟 CPU 使用率)参数,用 RunCat 的速度公式
  interval = 0.2 / clamp(load/5, 1, 20) 换算成换帧节奏
- 像素图,用 imageRendering: pixelated 保持锐利

npx create-video@latest 起模板后,Claude Code 会把组件、Root.tsx 注册、public/ 资源路径一次性搭好。帧素材可以直接扒 RunCat 仓库 resources/cat/png/ 里的 5 张图,或者让 AI 图像工具生成一套你自己的 5 帧奔跑序列。

第二步:核心 sprite 组件

这是全文的重点。RunCat 的「取模换帧」在 Remotion 里长这样:

// src/RunningCat.tsx
import {
  AbsoluteFill, Img, staticFile,
  useCurrentFrame, useVideoConfig,
} from 'remotion';

const CAT_FRAMES = [0, 1, 2, 3, 4].map((i) => staticFile(`cat/${i}.png`));

export const RunningCat: React.FC<{ load: number }> = ({ load }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 照搬 RunCat 的速度公式
  const speed = Math.max(1, Math.min(20, load / 5));
  const secondsPerSprite = 0.2 / speed;            // 每张 sprite 停留的秒数
  const framesPerSprite = Math.max(               // 换算成「视频帧」
    1,
    Math.round(secondsPerSprite * fps),
  );

  // 灵魂取模:帧号 → 第几张 sprite
  const spriteIndex =
    Math.floor(frame / framesPerSprite) % CAT_FRAMES.length;

  return (
    <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
      <Img
        src={CAT_FRAMES[spriteIndex]}
        style={{ height: 144, imageRendering: 'pixelated' }}
      />
    </AbsoluteFill>
  );
};

关键就那一行 Math.floor(frame / framesPerSprite) % CAT_FRAMES.length:

第三步:注册 Composition

// src/Root.tsx
import { Composition } from 'remotion';
import { RunningCat } from './RunningCat';

export const RemotionRoot: React.FC = () => (
  <Composition
    id="RunningCat"
    component={RunningCat}
    durationInFrames={60}     // 见下方「无缝循环」的坑
    fps={30}
    width={144}
    height={144}
    defaultProps={{ load: 50 }}
  />
);

无缝循环的坑:GIF 要循环不跳帧,durationInFrames 必须是「一个完整动画周期」的整数倍,也就是 framesPerSprite * 5 的倍数。比如 load=50 时 speed=10、framesPerSprite = round(0.02*30)=1,一个周期 5 帧,那 durationInFrames 取 5 的倍数(如 60)才能首尾对齐。这个细节 Claude Code 不一定主动帮你算,得自己盯一下。

第四步:渲染成 GIF

# 基础渲染
npx remotion render RunningCat out/cat.gif --codec=gif

# 透明背景(sprite 是抠好的 PNG 时必备)
npx remotion render RunningCat out/cat.gif --codec=gif --image-format=png

# 降帧压体积:只渲染每 2 帧,30fps → 15fps
npx remotion render RunningCat out/cat.gif --codec=gif --every-nth-frame=2

# 无限循环(GIF 默认就是无限循环,这里显式写出)
npx remotion render RunningCat out/cat.gif --codec=gif --number-of-gif-loops=null

跑完你就得到一只无限循环、背景透明的奔跑猫 GIF,可以塞进博客、README、PPT 或者任何网页。

进阶:让猫「真的」随负载变速

上面 load 是个固定值,猫匀速跑。想复刻 RunCat 那种「负载飙升猫就抽风」的动态感,有两条路。

渲染成动态 GIF:用 interpolate 让 load 随帧号变化,模拟一次 CPU 尖峰:

import { interpolate } from 'remotion';

const load = interpolate(
  frame,
  [0, 45, 90, 135, 180],
  [10, 95, 30, 100, 10],   // 闲 → 爆 → 缓 → 再爆 → 闲
  { extrapolateRight: 'clamp' },
);

一个容易被忽略的深度坑:当速度随帧动态变化时,Math.floor(frame / framesPerSprite) 里的 framesPerSprite 每帧都在变,除法结果不再单调递增,sprite 会跳帧。严格正确的做法是对速度做相位累积(把每帧的换帧增量累加起来),而不是简单除法。RunCat 靠有状态的 index++ 天然规避了这个问题,但 Remotion 的纯函数模型逼你把「累积」显式写出来——这正是命令式转声明式最反直觉的地方。做匀速 demo 可以偷懒用除法,做严肃动态动画就得老实累积。

驱动实时网页版:如果你要的不是视频而是一个活的网页挂件,用 @remotion/player 把同一个组件搬进浏览器,再把真实性能指标喂给 load,就是一个跑在网页里的实时 RunCat。同一份组件,既能渲染成 GIF,又能实时播放——这是 Remotion 相比纯 CSS 动画的一大优势。

Claude Code 在这条流程里加速了什么

说句实在话,Remotion 的 API 不难,难的是「纯函数帧模型」的思维转换。Claude Code 的价值分三层:

但**「取模换帧」的原理、「相位累积」的坑、无缝循环的数学**——这些判断你得心里有数,否则 AI 给的代码你既 review 不了也调不动。这也印证了我一直的观点:AI 拉满执行力,判断框架还得是自己的。

要不要这么搞:我的判断

一只 5 帧的猫,串起了 sprite 动画原理、纯函数帧模型、和 AI 辅助的范式翻译。有时候最好的学习项目,就是把一个你每天都在看的小东西,亲手拆开再装回去。

参考来源:menubar_runcat 源码 · Remotion useCurrentFrame 文档 · Remotion 渲染 GIF 文档