关于 motion-quality
面向 Remotion 产品演示场景和组件开发者的运动质量技能,介绍逐帧测量检查,以及文本动画、渲染、预览和布局的实现实践。
主要功能
- 01帧差与运动检查
- 02亚像素几何测量
- 03文本缩放与字体微调指南
- 04渲染与 Player 优化
- 05预览和布局问题排查
适用场景
- 01测量动画是否在帧间停滞
- 02减少缩放文本的抖动
- 03诊断渲染演示与网站显示差异
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ shadcn-labs/ snapcn --skill motion-quality安装后在 Claude Code 中输入/motion-quality确认已加载。
详细介绍
动效质量
这里的每条规则都是付出代价换来的。每一条都对应一个已发布的 Bug,我们在渲染帧上测量过它,并且它让某位用户耗掉了整个下午。这些数字都是真实的——它们就在文件里,让你无法说服自己违背规则。
规则 0 — 你看不见亚像素 Bug。测量帧。
“看起来没问题”并不能证明动画在帧级别上准确。渲染帧并测量。四项检查几乎能抓出所有问题:
| 内容 | 原理 | 失败时的表现 |
|---|---|---|
| 没有任何动画的连续帧必须逐字节相同 | 样式没有变化,因此像素也必须相同 | 差异非零 = 渲染器不确定 |
| 在单调缩放下,墨迹的垂直质心必须单调移动 | 这是纯仿射映射 | 方向反转 = 字体正在吸附到像素网格 |
缩放时,ink area / width² 不会变化 | 这是刚性形状的不变量 | 漂移 = 字形轮廓正在改变形状(网格拟合) |
| 动画期间,任何一帧的位移都不应小于 0.5px | 亚像素运动会栅格化为相同帧 | 连续出现多帧 = 动画明显卡住 |
从抗锯齿中恢复亚像素几何信息:alpha = (pixel - bg) / (fg - bg),然后计算强度加权质心。精度约为 0.01px,远高于你要排查的 Bug 所需精度。
这四项检查现在都已写成代码——别再手动重新推导了。 lib/motion-check 是测量库(settleFrame、holdIsStill、centroidMonotonic、shapeInvariant、noFrozenFrames、edgeBleedDelta、detailCoverage),纯函数,并经过合成光栅测试;lib/motion-check/capture.ts 可将组件的帧直接渲染到内存中(一个 bundle、一个浏览器、PNG 缓冲区,无 mp4——h264 会量化掉 alpha 恢复所需的抗锯齿信息)。
pnpm run motion:measure --only=text-build # -> registry/__measured__.json
它还回答了测试无法回答的两个问题:组件何时稳定(某段动画仍在播放时,下一段已淡入覆盖其上——@remotion/transitions 会重叠 18 帧),以及它最多能容纳多少文案,才不会让文字跑出画面。
如果有人说动画还是不流畅,而你的指标显示问题已修复,那说明你的指标测错了东西。 不要和他们争辩。去找到能复现他们所见问题的指标。这个仓库里就有两个独立的 Bug,正是这样被漏掉的。
让缩放文本显得廉价的两个 Bug
浏览器不会像缩放图像那样缩放文本。它们会在每个字形变为新尺寸时重新塑形并重新栅格化。这里有两个独立的问题,需要分别修复。
1. “卡住”——字形原点在垂直方向吸附到整数像素
栅格化器会量化每个字形的原点:水平方向精度为四分之一像素,垂直方向则完全没有亚像素精度——它会舍入到整设备像素(Skia,SkGlyphPositionRoundingSpec::HalfAxisSampleFreq)。因此,改变基线位置的缩放会让文字沿像素网格逐像素跳动。在缓动曲线的慢速端,基线每帧只漂移一小部分像素,连续几帧舍入后毫无变化,随后又一次跳动整整一个像素。静止、跳动、再静止。
修复方法:以基线为缩放枢轴。 这样基线的设备坐标 Y 永远不会改变,也就不会发生吸附。
// measure it — never guess it from a line-height ratio
<span ref={lineRef} style={{ transformOrigin: `0% ${baseline}px`, scale }}>
{…}
{/* an empty zero-sized inline-block sits ON the baseline */}
<span ref={baselineRef} style={{ display: "inline-block", width: 0, height: 0 }} />
</span>
// baseline = baselineRef.current.offsetTop
对 transformOrigin 进行扫描,在 1.6x → 1x 的线性渐变中测量结果:
| 枢轴 | 垂直抖动 | 方向反转(40 帧) |
|---|---|---|
50%(中间) | 0.284px | 29 |
| 74% | 0.273px | 6 |
| 80%(基线) | 0.014px | 0 |
| 90% | 0.284px | 0 |
只有基线位置出现了明显的最小值,其他位置都没有。逐字母变换也需要使用相同的枢轴——50% 100% 指的是文本框底部,它位于基线下方,差距就是下伸部分的高度,因此缩放时会拖动字母的基线。
2. “抖动”——字体提示让字形沸腾
字体提示会弯曲每个字形的轮廓,让笔画落在整像素上。随着字号变化,每根笔画都会重新对齐到不同的网格,**字母的形状真的会逐帧改变。**它们会沸腾。
**解决方法:text-rendering: geometricPrecision。**它会关闭字体提示,并按实际轮廓渲染。
在一次缩放过程中,使用不会改变的形状不变量进行测量:
| 形状偏移 | |
|---|---|
| 开启字体提示(默认) | 3.41% |
geometricPrecision | 0.22% |
稳定性提高了 15 倍。关闭字体提示后,文字看起来会稍微软一些。那不是模糊——而是没有谎言;所有专业动效工具都是这样处理文字的。它还会强制开启亚像素字形定位,而 Blink 通常只会在设备缩放因子大于 1 时启用此功能;Remotion 渲染的缩放因子恰好为 1,因此在 Linux 上(实际运行渲染的系统),如果不开启此功能,两个轴都会对齐到整像素。
will-change: transform——适合 Player,不适合渲染
它会把缩放交给合成器处理,合成器重新采样的是位图,而非重新栅格化真实文字。这个取舍在一处是正确的,在另一处则是错误的:
- 渲染会分散到多个并行的浏览器标签页中,每个标签页都会继承它上次绘制时对应缩放比例下的旧栅格图。测量结果:样式逐字节完全相同的帧,竟然产生了4 种不同的栅格化结果;在画面静止的停留期间,每帧还有约 9,000 个像素发生变化。文字静止时也会闪烁。而且文字变成了缩放后的纹理,不再是真正的文字。
- Player 是一个连续运行的标签页,在 120Hz 下每帧只有约 8ms 的严格预算。每帧都以全新字号重新塑造一行文字,是要求浏览器逐帧执行的最昂贵操作。单个连续运行的标签页上的测量结果:抖动幅度为 0.016px,完全没有损失清晰度。
因此要有条件地启用它:
import { getRemotionEnvironment } from "remotion";
...(getRemotionEnvironment().isRendering ? null : { willChange: "transform" as const }),
盲目照搬——这些都不是解决方法
translateZ(0)、translate3d(…, 0)、backface-visibility: hidden、perspective(1px)、rotateY(0.01deg)、filter: blur(0px)。这些做法本质上都是同一种图层提升技巧,只是换了不同的外衣;它们通过把文字变成位图来换取平滑效果。-webkit-font-smoothing: antialiased 完全不会影响字形定位。用 font-size 动画替代 scale 更糟——它不仅会逐帧重新排版,还会触发字形对齐。
帧时钟上的缓动
Quint-out 和 expo-out——大家最常用的曲线——会在前 1/3 的时间里完成 99% 的运动,随后缓慢爬行。在帧时钟上,这种爬行不是优雅的停稳,而是连续重复的帧。
在 30fps 下,使用 cubic-bezier(0.22, 1, 0.36, 1) 对上升 50px 的动画进行测量:**14 帧中有 5 帧每帧移动不足半个像素。**这些帧栅格化后的结果完全相同。文字明显在上升到一半时就骤然停下,然后等待。
停稳过程只需一帧,才算停稳。停稳过程耗时五帧,就是冻结。
使用适度减速曲线——cubic-bezier(0.2, 0.6, 0.35, 1)——并按照上述 < 0.5px 测试检查每条曲线。曲线仍然应该平稳停下,但不能把看不见的运动浪费在多帧上。
会让你踩坑的布局陷阱
inline-block内的尾随空格会被去掉。 它位于该盒子行尾,CSS 会将其移除,因此按词拆分的 span 会呈现为Noextracharge。把分隔符放在 span 之间,不要放在 span 内。offsetWidth/offsetLeft的单位是布局 px——不受变换影响,这正是它们适合测量即将进行变换的场景的原因。但offsetTop会舍入为整数;需要亚像素精度时使用getBoundingClientRect()(并记住它会受变换影响)。- **只测量一次,并放在
delayRender()后面,**且仅在测量结果重新渲染后才调用continueRender()——否则第 0 帧会以错误的几何信息被捕获。 - 变换不会触发布局重排。 只对
scale/translate/opacity设置动画,不要动其他属性,这样基线就会固定,也不会发生布局偏移。
实时 Player 会骗人。有些组件必须附带渲染好的演示。
<Player> 不是视频——它是 React 在实时 requestAnimationFrame 中重新渲染。120Hz 显示器上的 30fps 合成必须让每一帧恰好保持 4 次刷新;错过帧预算的帧会显示错误时长,看起来就会卡顿、抖动。慢速平滑运动时问题最严重,而标题揭示效果恰恰就属于这种情况。
对于这些场景,预览会让一个正确的组件看起来像是坏了。解决办法是不再预览近似效果:将 slug 添加到 lib/rendered-demos.tsx 中的 RENDERED_DEMOS,网站就会播放真正的 mp4。请参阅 CONTRIBUTING.md。
如果你修改了 RENDERED_DEMOS 中的组件,也要在同一次更改中重新渲染其演示——运行 pnpm run render:previews --only <slug>。没有任何机制会检测演示是否过期。
接线预览时要留意标识符与标签的区别:PreviewStage 同时接收 slug(注册表键)和 name(人类可读标签)。把 "Text Swell" 传给本应接收 slug 的参数,会导致查找静默失败,并回退到会卡顿的 Player,看起来就像这个功能从未接入过一样。
渲染结果没有全局 CSS,网站却有。
Remotion bundle 不会加载应用的样式表,因此组件即使在 mp4 中逐帧检查都正常,也可能在安装它的应用中完全不可见。这不是假设——它曾经真的发生过:
Tailwind 的 preflight 会设置 img, video { max-width: 100% }。该规则会应用于 img,不会应用于 svg。一个放在绝对定位盒子中、位于 left: 100% 的标记,可用宽度为 (包含块 − left) = 0,因此盒子会收缩至适合内容的大小,并向内容询问其最小尺寸。<svg width={58}> 会回答“58”,于是盒子就保持了宽度。受 preflight 影响的 <img> 会回答**“我可以是 0”——盒子随之塌缩,0 的 100% 还是 0,图像最终会以0 像素宽度**渲染。渲染出的 mp4 完美无缺。文档页面却是一块空白的白色矩形,而 <Player> 就在那里,看起来像是卡住了。
因此:组件接收图像时,要在网站上验证,而不只是查看渲染结果。 加载页面并读取元素的实际几何信息——对 <img> 调用 getBoundingClientRect() 得到 0 x 17,经过一番无果的猜测后,这一下立刻揭示了 bug。还要为组件做好防护:对任何自行设置尺寸的图像使用 max-width: none,对任何定位在边缘、会收缩至适合内容的盒子使用 width: max-content。
除非提前规划,否则透视式膨胀会在一帧内充满画面
在 scale = 1 / (1 - travel) 下,顶部附近的所有尺寸阈值都会在彼此相差一两帧内被跨越。如果等到标记超过画面对角线才触发“现在覆盖屏幕”事件,就会出现一帧内从覆盖 23% 跃升到覆盖 91% 的情况:这不是铺满,而是切换。提前开启触发条件——当标记仅仅高于画面时——同一个事件就有五六帧的时间完成,而且绝不会在标记还很小时就开始。
而且,徽标无法靠放大来覆盖画面:缩放徽标也会按完全相同的比例放大其中的空洞。测量一个墨色覆盖率为 28% 的标记后发现,实色覆盖需要放大 396 倍。结尾必须让标记的颜色从内部向外涌出——并且必须是一个形状(从标记自身的墨色中生成的硬边圆盘),绝不能是透明度渐变。将全屏矩形逐渐淡入,会让半透明的色层在背景上停留好几帧,而观众只会将其理解为“画面淡变成蓝色”。
仓库注意事项
- **绝不要对
registry/snap-cn/registry.json运行格式化工具。**它会重新排版整个文件,让你的单个条目淹没在数百行噪声中。 pnpm run registry:build会重写所有public/r/*.json,而不只是你自己的文件。如果其他组件有尚未提交的源码修改,它们的输出也会重新生成。检查git status并说明这一点。- 重新渲染演示,并不意味着演示真的重新渲染了。
public/demos/<slug>.mp4永远沿用相同的文件名;浏览器一旦缓存了该文件,就会一直重播——<video>的缓存尤其顽固。我们因此在已不存在的构建上,经历了三轮“你没修好”。现在已经妥善解决:render:previews会将每个演示的哈希写入lib/demo-manifest.json,而renderedDemoSrc()会将该哈希放入 URL(?v=<hash>),因此重新渲染的演示会拥有不同的地址,缓存无法再提供过时版本。next.config.ts也会在开发环境中为/demos/*发送no-store。 如果你手动重新渲染演示,请重新运行render:previews,让清单同步更新——过时的清单意味着过时的演示。而当有人说“你没修好”时,在相信或质疑对方之前,先检查磁盘上的文件(md5)以及服务器实际提供的内容。 - **对照参考,不要凭感觉判断。**用户提供参考视频时,提取视频帧并测量实际曲线(使用
ffmpeg,对墨色进行阈值处理并追踪边界框)。text-reveal和text-swell中的每个编排细节——向前推进、两个时钟、字母膨胀、哪些词语会弹跳——都是通过测量参考视频得出的,而这些细节全都肉眼难以察觉。