motion-quality

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Motion quality

动效质量

Every rule here was paid for. Each one is a bug that shipped, was measured on rendered frames, and cost a user their afternoon. The numbers are real — they are in the file so you cannot talk yourself out of the rule.

这里的每条规则都是用代价换来的。每一个都对应一个曾经发布、在渲染帧上经过测量,并让用户搭上一个下午的 bug。数字都是真实的——它们已经写进文件里,所以你没法用话术绕开这些规则。

Rule 0 — you cannot see a sub-pixel bug. Measure the frames.

规则 0——你无法肉眼看出亚像素级 bug。要去测量帧。

"It looks fine" is not a claim about a frame-accurate animation. Render the frames and measure them. Four checks catch almost everything:
whatwhy it workswhat a failure looks like
Consecutive frames where nothing is animating must be byte-identicalthe style is unchanged, so the pixels must benon-zero diff = the renderer is non-deterministic
Under a monotone scale, the ink's vertical centroid must move monotonicallyit is a pure affine mapdirection reversals = the type is snapping to the pixel grid
ink area / width²
cannot change under a scale
it is a shape invariant of a rigid shapedrift = the glyph outlines are changing shape (hinting)
No frame should move < 0.5px while it is supposed to be animatingsub-pixel motion rasterises to an identical framea run of them = the animation is visibly frozen
Recover sub-pixel geometry from the antialiasing:
alpha = (pixel - bg) / (fg - bg)
, then take intensity-weighted centroids. That is accurate to ~0.01px, which is far finer than the bugs you are hunting.
All four checks are code now — stop re-deriving them by hand.
lib/motion-check
is the measurement library (
settleFrame
,
holdIsStill
,
centroidMonotonic
,
shapeInvariant
,
noFrozenFrames
,
edgeBleedDelta
,
detailCoverage
), pure and tested on synthetic rasters;
lib/motion-check/capture.ts
renders a component's frames straight into memory (one bundle, one browser, PNG buffers, no mp4 — h264 quantises away the very antialiasing the alpha recovery reads).
bash
pnpm run motion:measure --only=text-build   # -> registry/__measured__.json
It also answers the two questions no test could: when a component settles (a beat still animating when the next fades in over it — @remotion/transitions overlaps 18 frames), and how much copy it can hold before the line runs off the frame.
If someone says it is still not smooth and your metric says it is fixed, your metric is measuring the wrong thing. Do not argue with them. Go find the metric that reproduces what they see. Two separate bugs in this repo were missed exactly this way.

“看起来没问题”不能作为帧精确动画的依据。渲染并测量这些帧。下面四项检查几乎可以发现所有问题:
检查项有效原因失败表现
没有任何内容处于动画状态时,连续帧必须逐字节完全一致style 没有变化,因此像素也必须相同差异非零 = renderer 不具备确定性
在单调 scale 下,ink 的垂直质心必须单调移动它是一个纯 affine map方向发生反转 = 文字正在吸附到 pixel grid
在 scale 变化时,
ink area / width²
不得发生变化
这是刚性形状的形状不变量数值漂移 = glyph outlines 正在改变形状(hinting)
本应处于动画状态时,任何一帧的移动量都不应小于 0.5px亚像素运动会栅格化成完全相同的帧连续出现这种情况 = 动画看起来已经冻结
从抗锯齿信息中恢复亚像素几何结构:
alpha = (pixel - bg) / (fg - bg)
,然后计算以 intensity 为权重的 centroid。其精度约为 ~0.01px,远比你要排查的问题精细。
这四项检查现在都已经写成代码——别再手工重新推导。
lib/motion-check
是测量库(
settleFrame
、
holdIsStill
、
centroidMonotonic
、
shapeInvariant
、
noFrozenFrames
、
edgeBleedDelta
、
detailCoverage
),它是 pure 的,并已在 synthetic rasters 上完成测试;
lib/motion-check/capture.ts
会将组件的帧直接渲染到内存中(一个 bundle、一个 browser、PNG buffers,不使用 mp4——h264 会量化掉 alpha 恢复所要读取的抗锯齿信息)。
bash
pnpm run motion:measure --only=text-build   # -> registry/__measured__.json
它还回答了两个测试无法回答的问题:组件何时 settle(下一段内容淡入并覆盖其上时,前一段仍在动画——@remotion/transitions 会重叠 18 帧),以及一行文字在溢出画面之前能够容纳多少 copy。
如果有人说它仍然不流畅,而你的 metric 表示问题已经修复,那么你的 metric 测量错了对象。 不要争论。去找一个能够复现他们所见效果的 metric。这个仓库中的两个独立 bug 正是因为这种做法而被漏掉。

The two bugs that make scaled text look cheap

让缩放文本显得廉价的两个 bug

Browsers do not scale text the way they scale an image. They re-shape and re-rasterise every glyph at the new size. Two things go wrong, they are independent, and you need both fixes.
浏览器缩放文本时,不会像缩放图像那样处理。它们会以新的尺寸重新塑形并重新 rasterise 每个字形。这里会发生两个彼此独立的问题,而两种修复都需要采用。

1. "Stuck" — glyph origins snap to whole pixels vertically

1.“卡住”——字形 origin 在垂直方向吸附到整数像素

The rasteriser quantises each glyph's origin: quarter-pixel precision horizontally, and none at all vertically — it rounds to a whole device pixel (Skia,
SkGlyphPositionRoundingSpec::HalfAxisSampleFreq
). So a scale that moves the baseline makes the type climb the pixel grid in whole-pixel jumps. At the slow ends of an eased curve the baseline drifts a fraction of a pixel per frame, which rounds to nothing for several frames and then to a whole pixel all at once. Sit still, jump, sit still.
Fix: pivot the scale on the baseline. Then the baseline's device Y never changes and there is nothing to snap.
tsx
// 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
Measured, sweeping
transformOrigin
across a linear 1.6x → 1x ramp:
pivotvertical judderdirection reversals (40 frames)
50%
(the middle)
0.284px29
74%0.273px6
80% (the baseline)0.014px0
90%0.284px0
A sharp minimum exactly on the baseline and nowhere else. Per-letter transforms need the same pivot —
50% 100%
is the bottom of the box, which sits below the baseline by the descent, so it drags the letter's baseline as it scales.
rasteriser 会量化每个字形的 origin:水平方向的精度为四分之一像素,垂直方向则完全没有精度——它会直接舍入到整个 device pixel(Skia,
SkGlyphPositionRoundingSpec::HalfAxisSampleFreq
)。因此,移动 baseline 的 scale 会让文字沿 pixel grid 以整像素为单位跳动。在 eased curve 的缓速阶段,baseline 每帧只漂移不到一个像素,于是连续数帧都被舍入为 0,随后又突然跳到整个像素。停住、跳动、再停住。
修复:以 baseline 作为 scale 的 pivot。 这样 baseline 的 device Y 永远不会改变,也就没有可供吸附的位移。
tsx
// 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
在线性 1.6x → 1x 变化过程中扫过不同的
transformOrigin
,实测结果如下:
pivot垂直 judder方向反转次数(40 帧)
50%
(中心)
0.284px29
74%0.273px6
80%(baseline)0.014px0
90%0.284px0
最小值恰好出现在 baseline 上,而且其他位置都没有这么低。逐字母 transform 也需要使用相同的 pivot——
50% 100%
是 box 的底部,它会因 descent 而位于 baseline 下方,因此会在缩放时拖动字母的 baseline。

2. "Shake" — hinting boils the letterforms

2.“抖动”——hinting 会让字形不断变化

Hinting bends each glyph's outline so its stems land on whole pixels. As the size slides, every stem re-snaps to a different grid and the letters literally change shape frame to frame. They boil.
Fix:
text-rendering: geometricPrecision
.
It turns hinting off and renders the outline as it actually is.
Measured over a fall-back, using the shape invariant that cannot change:
shape drift
hinting on (default)3.41%
geometricPrecision
0.22%
Fifteen times steadier. The type reads very slightly softer without hinting. That is not blur — it is the absence of a lie, and it is what every professional motion tool does with type. It also forces sub-pixel glyph positioning on, which Blink otherwise only enables above a device scale factor of 1 — and a Remotion render is exactly 1, so on Linux (where renders actually run) both axes snap to whole pixels without it.

Hinting 会弯曲每个字形的 outline,让 stem 对齐到整数像素。随着字号变化,每个 stem 都会重新吸附到不同的 grid,字母甚至会逐帧改变形状。这种效果被称为 boil(沸腾抖动)。
修复:使用
text-rendering: geometricPrecision
。
它会关闭 hinting,并按照 outline 的真实形态进行渲染。
在 fallback 字体上实测,使用不会发生变化的 shape invariant:
设置shape drift
hinting 开启(默认)3.41%
geometricPrecision
0.22%
稳定性提升了十五倍。关闭 hinting 后,文字会显得稍微柔和一些。那不是模糊——而是不再呈现一种虚假的形态,这也是每个专业 motion tool 处理文字时会采用的方式。它还会强制开启亚像素字形定位;否则 Blink 只会在 device scale factor 大于 1 时启用该功能,而 Remotion render 的 device scale factor 正好是 1,因此在 Linux(也就是实际执行 render 的环境)中,不使用它就会让两个轴都吸附到整数像素。

will-change: transform
— right for the Player, wrong for the render

will-change: transform
——对 Player 正确,对 render 错误

It hands the scale to the compositor, which resamples a bitmap instead of re-rasterising real type. That is the correct trade in one place and the wrong trade in the other:
  • A render is spread across parallel browser tabs, and each tab inherits a stale raster from whatever scale it drew last. Measured: frames whose style was byte-identical came out as 4 different rasterisations, and ~9,000 pixels changed per frame during a hold where nothing was moving. The text shimmers while standing still. And the type is a rescaled texture, not type.
  • The Player is one continuous tab with a hard ~8ms frame budget at 120Hz. Re-shaping a line of type at a brand-new size every frame is the most expensive thing you can ask a browser to do per frame. Measured on a single continuous tab: 0.016px judder, no loss of sharpness at all.
So gate it:
tsx
import { getRemotionEnvironment } from "remotion";
...(getRemotionEnvironment().isRendering ? null : { willChange: "transform" as const }),
它会把 scale 交给 compositor 处理,从而重新采样 bitmap,而不是重新 rasterise 真正的文字。在一种场景中,这是正确的取舍;在另一种场景中,则是错误的取舍:
  • render 分布在多个并行 browser tabs 中,每个 tab 都会沿用上一次所绘制 scale 对应的 stale raster。实测显示:style 逐字节完全一致的帧,最终却产生了 4 种不同的 rasterisation;在什么都没动的 hold 期间,每帧约有 9,000 个像素发生变化。文字静止时依然在闪烁,而且呈现出来的是缩放后的纹理,而不是真正的文字。
  • Player 是单个持续运行的 tab,在 120Hz 下有约 8ms 的硬性帧预算。每帧都以全新尺寸重新塑形一行文字,是浏览器单帧开销最大的操作之一。在单个持续运行的 tab 上实测:judder 为 0.016px,完全没有损失清晰度。
因此需要按环境进行控制:
tsx
import { getRemotionEnvironment } from "remotion";
...(getRemotionEnvironment().isRendering ? null : { willChange: "transform" as const }),

Cargo cult — these are not fixes

Cargo cult——这些并不是修复方案

translateZ(0)
,
translate3d(…, 0)
,
backface-visibility: hidden
,
perspective(1px)
,
rotateY(0.01deg)
,
filter: blur(0px)
. Every one is the same layer-promotion trick wearing a different hat, and they buy smoothness by turning your type into a bitmap.
-webkit-font-smoothing: antialiased
does not touch glyph positioning at all. Animating
font-size
instead of
scale
is worse — it reflows every frame and still hits glyph snapping.

translateZ(0)
、
translate3d(…, 0)
、
backface-visibility: hidden
、
perspective(1px)
、
rotateY(0.01deg)
、
filter: blur(0px)
。这些本质上都是同一套 layer-promotion 技巧,只是换了个形式;它们通过将文字变成 bitmap 来换取平滑。
-webkit-font-smoothing: antialiased
完全不会影响字形定位。用
font-size
代替
scale
做动画反而更糟——它不仅每帧都会触发 reflow,而且仍然会遭遇字形吸附。

Easing on a frame clock

帧时钟上的 easing

Quint-out and expo-out — the curves everyone reaches for — cover 99% of their travel in the first third and then crawl. On a frame clock that crawl is not a graceful settle, it is identical frames.
Measured on a 50px rise at 30fps with
cubic-bezier(0.22, 1, 0.36, 1)
: 5 of its 14 frames moved less than half a pixel each. They rasterised identically. The word visibly stopped dead partway up and waited.
A settle worth one frame is a settle. A settle worth five frames is a freeze.
Use a moderate decelerate —
cubic-bezier(0.2, 0.6, 0.35, 1)
— and check every curve against the < 0.5px test above. Curves should still arrive at a standstill; they just must not spend frames on travel you cannot see.

Quint-out 和 expo-out 是大多数人最先想到的曲线——它们在前三分之一行程内就完成了 99% 的位移,随后才开始缓慢爬行。在帧时钟上,这种爬行并不是优雅的 settle,而是完全相同的帧。
在 30fps 下使用
cubic-bezier(0.22, 1, 0.36, 1)
让 50px 的内容上升,实测结果是:14 帧中有 5 帧的移动量不到半像素。这些帧的栅格化结果完全相同。这个词在上升途中明显骤然停住,然后原地等待。
值得一帧的 settle 才叫 settle。值得五帧的 settle 就是冻结。
使用适度的减速曲线——
cubic-bezier(0.2, 0.6, 0.35, 1)
——并根据上面的 < 0.5px 测试检查每条曲线。曲线仍然应当抵达静止状态,只是不要把帧浪费在肉眼无法看见的位移上。

Layout gotchas that will bite you

迟早会咬到你的布局陷阱

  • A trailing space inside an
    inline-block
    gets stripped.
    It sits at the end of that box's line and CSS removes it, so per-word spans render as
    Noextracharge
    . Put the separator between the spans, not inside them.
  • offsetWidth
    /
    offsetLeft
    are layout px
    — unaffected by transforms, which is exactly why they are the right tool for measuring a scene you are about to transform. But
    offsetTop
    is rounded to an integer; use
    getBoundingClientRect()
    when you need sub-pixel (and remember it is affected by transforms).
  • Measure once, behind
    delayRender()
    , and
    continueRender()
    only after the measurement has re-rendered — otherwise frame 0 is captured with the wrong geometry.
  • Transforms do not reflow. Animate
    scale
    /
    translate
    /
    opacity
    and nothing else, so the baseline is fixed and there is no layout shift.

  • inline-block
    末尾的空格会被移除。
    空格位于该 box 所在行的末尾,CSS 会将其删除,因此逐单词 spans 会渲染成
    Noextracharge
    。应将分隔符放在 spans 之间,而不是内部。
  • offsetWidth
    /
    offsetLeft
    是布局 px
    ——不受 transform 影响,因此正适合测量即将执行 transform 的 scene。但
    offsetTop
    会被舍入为整数;需要亚像素精度时应使用
    getBoundingClientRect()
    (同时记得它会受到 transform 影响)。
  • 在
    delayRender()
    后面测量一次
    ,并且只有在测量所触发的重新渲染完成后才能调用
    continueRender()
    ——否则第 0 帧会使用错误的 geometry 被捕获。
  • Transform 不会触发 reflow。 只动画
    scale
    /
    translate
    /
    opacity
    ,不要动画其他属性,这样 baseline 会保持固定,也不会产生 layout shift。

The live Player lies. Some components must ship a rendered demo.

实时 Player 会骗人。有些组件必须附带渲染后的 demo 发布。

A
<Player>
is not a video — it is React re-rendering on
requestAnimationFrame
in real time. A 30fps composition on a 120Hz display must hold every frame for exactly four refreshes; a frame that misses its budget is shown for the wrong length of time, and the eye reads that as sticking and shaking. It is worst during slow smooth motion, which is exactly where a title reveal lives.
For those scenes the preview makes a correct component look broken. The answer is to stop previewing an approximation: add the slug to
RENDERED_DEMOS
in
lib/rendered-demos.tsx
and the site plays the real mp4 instead. See CONTRIBUTING.md.
If you change a component in
RENDERED_DEMOS
, re-render its demo in the same change
—
pnpm run render:previews --only <slug>
. Nothing catches a stale one.
Watch for identifier vs. label when wiring previews:
PreviewStage
takes both
slug
(the registry key) and
name
(a human label). Passing "Text Swell" where a slug was wanted made the lookup miss silently and fall back to the stuttery Player, and it looked exactly like the feature was never wired.

<Player>
不是视频——它是 React 在
requestAnimationFrame
上进行的实时重新渲染。30fps composition 显示在 120Hz 屏幕上时,必须让每一帧恰好保持四次刷新;如果某一帧超出预算,它的显示时长就会不正确,眼睛会将这解读为卡顿和抖动。对于缓慢、平滑的运动,这种问题最为严重,而标题 reveal 恰好就属于这种场景。
对于这些场景,preview 会让一个本来正确的组件看起来像是坏了。答案是不再预览近似效果:将 slug 添加到
lib/rendered-demos.tsx
的
RENDERED_DEMOS
中,网站就会改为播放真实的 mp4。参见 CONTRIBUTING.md。
如果修改了
RENDERED_DEMOS
中的组件,必须在同一次变更中重新渲染它的 demo
——执行
pnpm run render:previews --only <slug>
。没有任何机制能够发现过期的 demo。
配置 preview 时要留意 identifier 与 label 的区别:
PreviewStage
同时接收
slug
(registry key)和
name
(供人阅读的 label)。在需要 slug 的位置传入“Text Swell”,会导致查找静默失败,并回退到卡顿的 Player,看起来就像功能根本没有接入。

A render has no global CSS. The site does.

Render 没有全局 CSS,但网站有。

A Remotion bundle loads none of the app's stylesheets, so a component can be verified frame by frame in an mp4 and still be invisible in the app that installs it. This is not hypothetical — it shipped:
Tailwind's preflight sets
img, video { max-width: 100% }
. It applies to
img
and not to
svg
. A mark placed in an absolutely-positioned box at
left: 100%
has (containing block − left) = 0 available width, so the box is shrink-to-fit and asks its content for a minimum. An
<svg width={58}>
answers "58" and holds it open. An
<img>
under preflight answers "I can be 0" — the box collapses, 100% of 0 is 0, and the image renders zero pixels wide. The rendered mp4 was perfect. The docs page was a blank white rectangle, and the
<Player>
sat there looking stuck.
So: when a component takes an image, verify it on the site, not only in a render. Load the page and read back the element's real geometry —
getBoundingClientRect()
on the
<img>
said
0 x 17
, which named the bug instantly after a lot of guessing did not. And defend the component:
max-width: none
on any image you size yourself,
width: max-content
on any shrink-to-fit box you position at an edge.

Remotion bundle 不会加载应用的 stylesheet,因此某个组件即使能够在 mp4 中逐帧验证,也仍然可能在安装它的应用中完全不可见。这并非假设性问题——它已经实际发生过:
Tailwind 的 preflight 设置了
img, video { max-width: 100% }
。它适用于
img
,但不适用于
svg
。将一个标记放在
left: 100%
的绝对定位 box 中时,(containing block − left) = 0,可用宽度为 0,因此该 box 会采用 shrink-to-fit,并要求其内容提供一个最小宽度。
<svg width={58}>
会回答“58”,从而保持自身宽度;而处于 preflight 下的
<img>
可以回答 “我可以宽 0”——box 随即塌缩,100% 的 0 仍然是 0,最终图片的渲染宽度为 0 像素。
render 出来的 mp4 完全正常,文档页面却是一片空白矩形,
<Player>
看起来也像是卡住了。
因此:当组件使用图片时,必须在网站上验证,而不能只验证 render。 加载页面并读回元素的真实 geometry——对
<img>
调用
getBoundingClientRect()
得到
0 x 17
,在反复猜测之后,这个结果立刻指出了问题所在。同时也要保护组件:对于自行设置尺寸的图片,使用
max-width: none
;对于定位在边缘的 shrink-to-fit box,使用
width: max-content
。

Perspective rushes fill the frame in one frame unless you plan for it

如果不提前规划,perspective 会在一帧内迅速铺满画面

Under
scale = 1 / (1 - travel)
, every size threshold near the top is crossed within a frame or two of every other one. Gate a "now cover the screen" event on the mark exceeding the frame diagonal and you get 23% covered → 91% covered in a single frame: a cut, not a fill. Open the gate earlier — when the mark is merely taller than the frame — and the same event gets five or six frames to happen in, without ever being able to start while the mark is small.
And a logo cannot cover a frame by growing: scaling it scales its holes by exactly as much. Measured on a 28%-ink mark, solid-ink coverage needed 396x. The ending has to be the mark's colour flooding out from inside it — and it must be a shape (a hard-edged disc born inside the mark's own ink), never an opacity ramp. Fading a full-frame rectangle up leaves a half-transparent wash lying over the backdrop for several frames, and there is no reading of that except "the screen faded to blue".

在
scale = 1 / (1 - travel)
下,靠近顶部的每个尺寸阈值都会在彼此相差一两帧的时间内被跨越。如果将“现在覆盖屏幕”事件绑定到标记超过画面对角线的条件上,覆盖率会在单帧内从 23% 跃升至 91%:这是剪切,而不是填充。
应该更早打开这个 gate——当标记仅仅高于画面时就触发。这样,同一事件便有五六帧时间逐步完成,同时也绝不会在标记还很小的时候就开始。
此外,logo 无法通过放大来覆盖整个画面:缩放 logo 时,其内部空洞也会按完全相同的比例放大。对 ink 占比为 28% 的标记进行实测,要实现完整的 solid-ink 覆盖,需要放大 396x。
结尾必须让标记的颜色从其内部向外扩散,而且必须是一种形状(一个从标记自身 ink 中诞生的 hard-edged disc),绝不能使用 opacity ramp。淡入一个覆盖全画面的矩形,会在背景上留下持续数帧的半透明色块;这种效果除了“屏幕淡成蓝色”之外,没有任何合理的解读。

Repo gotchas

仓库中的陷阱

  • Never run a formatter over
    registry/snap-cn/registry.json
    .
    It reflows the whole file and buries your one entry in hundreds of lines of noise.
  • pnpm run registry:build
    rewrites every
    public/r/*.json
    , not just yours. If other components have uncommitted source edits, their outputs get regenerated too. Check
    git status
    and say so.
  • A re-rendered demo does not mean a re-rendered demo.
    public/demos/<slug>.mp4
    keeps the same filename forever, and a browser that already has one will keep replaying it —
    <video>
    caches hardest of all. That cost three rounds of "you didn't fix it" against builds that no longer existed. It is fixed properly now:
    render:previews
    hashes every demo into
    lib/demo-manifest.json
    and
    renderedDemoSrc()
    puts that hash in the URL (
    ?v=<hash>
    ), so a re-rendered demo is a different address and a stale one is not something a cache can serve.
    next.config.ts
    also sends
    no-store
    for
    /demos/*
    in dev. If you re-render a demo by hand, re-run
    render:previews
    so the manifest moves with it
    — a stale manifest is a stale demo. And when someone says "you didn't fix it": check what is on disk (
    md5
    ) and what the server actually serves before you believe them, and before you disbelieve them.
  • Match the reference, do not eyeball it. When a user gives you a reference video, extract the frames and measure the actual curves (
    ffmpeg
    + threshold the ink + track bounding boxes). Every choreography detail in
    text-reveal
    and
    text-swell
    — the forward push, the two clocks, the letter swell, which words bounce — came out of measuring the reference, and every one of them was invisible to the naked eye.
  • 绝不要对
    registry/snap-cn/registry.json
    运行 formatter。
    它会重排整个文件,并将你新增的唯一条目淹没在数百行噪声之中。
  • pnpm run registry:build
    会重写所有
    public/r/*.json
    ,而不只是你修改的文件。如果其他组件存在尚未提交的源码修改,它们的输出也会被重新生成。检查
    git status
    ,并明确说明这一点。
  • 重新渲染了 demo,并不等于用户看到的 demo 已经重新渲染。
    public/demos/<slug>.mp4
    的文件名永远保持不变,而浏览器中已经存在的文件会继续被重复播放——
    <video>
    的缓存尤其顽固。这让已经不复存在的构建版本白白经历了三轮“你没有修复它”的追查。现在已经 properly fix:
    render:previews
    会为每个 demo 计算 hash 并写入
    lib/demo-manifest.json
    ,
    renderedDemoSrc()
    会把该 hash 放入 URL(
    ?v=<hash>
    ),因此重新渲染后的 demo 会对应一个不同地址,缓存无法再提供旧版本。
    next.config.ts
    在开发环境中也会为
    /demos/*
    发送
    no-store
    。 如果手动重新渲染了 demo,请再次运行
    render:previews
    ,让 manifest 同步更新
    ——manifest 过期就意味着 demo 过期。当有人说“你没有修复它”时,先检查磁盘上的内容(
    md5
    )以及服务器实际返回的内容,再决定相信或不相信对方。
  • 匹配 reference,不要凭肉眼判断。 当用户提供 reference video 时,应提取帧并测量实际曲线(
    ffmpeg
    + 对 ink 进行 threshold + 跟踪 bounding boxes)。
    text-reveal
    和
    text-swell
    中的每一个编排细节——向前推进、两个时钟、字母 swell,以及哪些单词会产生弹跳——都来自对 reference 的测量,而且每一个细节都无法用肉眼察觉。