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を入力して読み込まれたか確認します。
詳細
モーションの品質
ここにある各ルールには、代償が伴いました。それぞれ、リリース後に発覚し、レンダリングされたフレームで計測され、ユーザーの午後を台無しにしたバグです。数値は実測値です。ルールを自己正当化して無視できないよう、ファイルに記載しています。
ルール 0 — サブピクセルのバグは目で見えません。フレームを計測してください。
「問題なさそう」は、フレーム精度のアニメーションについての根拠にはなりません。フレームをレンダリングして計測してください。次の 4 つのチェックで、ほぼすべての問題を検出できます。
| 対象 | 有効な理由 | 失敗時の状態 |
|---|---|---|
| 何もアニメーションしていない連続フレームは、バイト単位で同一でなければなりません | スタイルに変化がないため、ピクセルにも変化がないはずです | 差分が 0 でない = レンダラーが非決定的です |
| 単調なスケールでは、インクの垂直方向の重心も単調に移動しなければなりません | 純粋なアフィン変換だからです | 移動方向の反転 = 文字がピクセルグリッドにスナップしています |
スケールしても ink area / width² は変化しません | 剛体の形状における形状不変量だからです | 値のずれ = グリフのアウトライン の形状が変化しています(ヒンティング) |
| アニメーション中のフレームは、どれも 0.5px 未満しか移動しない状態になってはいけません | サブピクセルの動きは、ラスター化すると同一フレームになります | この状態が連続して発生 = アニメーションが目に見えて停止しています |
アンチエイリアスからサブピクセルのジオメトリを復元します。alpha = (pixel - bg) / (fg - bg) を使い、その後、強度で重み付けした重心を求めます。精度は約 0.01px で、検出対象のバグよりはるかに細かく計測できます。
4 つのチェックはすべてコード化されています。手作業で再導出するのはやめてください。 lib/motion-check は計測ライブラリ(settleFrame、holdIsStill、centroidMonotonic、shapeInvariant、noFrozenFrames、edgeBleedDelta、detailCoverage)です。純粋関数であり、合成ラスターを使ってテスト済みです。lib/motion-check/capture.ts はコンポーネントのフレームを直接メモリにレンダリングします(1 つのバンドル、1 つのブラウザー、PNG バッファー、mp4 なし。h264 はアルファ復元で読み取るアンチエイリアスを量子化して失わせるためです)。
pnpm run motion:measure --only=text-build # -> registry/__measured__.json
さらに、テストでは答えられない 2 つの問いにも答えます。コンポーネントがいつ落ち着くか(次のビートがフェードインするときも前のビートがまだアニメーションしている状態です。@remotion/transitions は 18 フレーム重なります)と、行がフレームからはみ出ることなく表示できるコピーの量です。
まだ滑らかではないと誰かが言っていて、あなたの指標では修正済みになっているなら、その指標は間違ったものを計測しています。 相手に反論してはいけません。相手が見ているものを再現できる指標を見つけてください。このリポジトリでは、まさにこの理由で別々の 2 つのバグを見逃しました。
スケールしたテキストを安っぽく見せる 2 つのバグ
ブラウザーは画像と同じ方法でテキストをスケールしません。新しいサイズに合わせて、各グリフを再形成し、再ラスター化します。独立した 2 つの問題が起きるため、両方を修正する必要があります。
1. 「引っかかり」 — グリフの原点が垂直方向に整数ピクセルへスナップする
ラスターライザーは各グリフの原点を量子化します。水平方向は 1/4 ピクセル精度ですが、垂直方向は精度がありません。デバイスピクセル単位の整数に丸められます(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
線形の 1.6x → 1x ランプで transformOrigin を変化させて計測した結果:
| 基点 | 垂直方向のガタつき | 移動方向の反転(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は、グリフの配置にはまったく影響しません。scaleではなくfont-sizeをアニメーションさせるのはさらに悪い方法です。毎フレーム再レイアウトされるうえ、グリフのスナップも発生します。
フレームクロックに合わせたイージング
誰もが使いたがるクイントアウトやエクスポアウトのカーブは、移動量の99%を最初の3分の1で消化し、その後はゆっくり進みます。フレームクロックでは、そのゆっくりした動きは優雅な停止ではなく、同一フレームの連続です。
cubic-bezier(0.22, 1, 0.36, 1)を使い、30fpsで50px上昇させた場合の測定結果:14フレーム中5フレームで、移動量がそれぞれ0.5ピクセル未満でした。 ラスタライズ結果は同一です。単語は上昇の途中で目に見えてぴたりと止まり、そのまま待機しました。
1フレーム分の停止は停止です。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> は動画ではありません。リアルタイムで requestAnimationFrame によって再レンダリングされる React です。120Hz のディスプレイで 30fps のコンポジションを表示する場合、各フレームを正確に 4 回のリフレッシュにわたって保持する必要があります。フレームが予算時間に間に合わないと、誤った時間だけ表示され、目には引っかかりや揺れとして映ります。ゆっくりと滑らかな動きの最中に最も顕著になりますが、タイトルの表示アニメーションはまさにその動きです。
このようなシーンでは、プレビューによって正しいコンポーネントが壊れているように見えます。近似プレビューをやめるのが解決策です。lib/rendered-demos.tsx の RENDERED_DEMOS に slug を追加すると、サイトでは実際の mp4 が再生されます。CONTRIBUTING.md を参照してください。
RENDERED_DEMOS 内のコンポーネントを変更した場合は、同じ変更でそのデモも再レンダリングしてください — pnpm run render:previews --only <slug>。古いデモを検出する仕組みはありません。
プレビューを接続するときは、識別子とラベルの違いに注意してください。PreviewStage は slug(レジストリキー)と name(人が読むためのラベル)の両方を受け取ります。slug が必要な場所に「Text Swell」を渡すと、検索は黙って失敗し、カクつく Player にフォールバックします。そのため、機能がまったく接続されていないように見えます。
レンダリングにはグローバル CSS がありません。サイトにはあります。
Remotion のバンドルではアプリのスタイルシートが読み込まれないため、コンポーネントは 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 を返していました。何度も推測しても分からなかったバグが、これによってすぐに特定できました。また、コンポーネント側でも対策してください。自分でサイズを指定する画像には max-width: none を、端に配置する幅が内容に合わせて縮小されるボックスには width: max-content を指定します。
遠近感を伴う拡大は、対策しないと一瞬でフレームを埋め尽くします
scale = 1 / (1 - travel) の場合、上限に近いサイズのしきい値は、どれも互いに1〜2フレーム以内に超えます。「今すぐ画面を覆う」イベントを、マークがフレームの対角線を超えたときに発生させると、1フレームで23%から91%まで覆われます。これは画面を埋めるのではなく、カットです。発生条件を早め、マークがフレームより高くなった時点で発動させれば、マークが小さいうちに始まることなく、同じイベントに5〜6フレームかけられます。
また、ロゴは拡大してもフレームを覆えません。拡大すると、ロゴの穴もまったく同じ倍率で拡大するからです。インク面積率28%のマークで測定したところ、単色のインクで覆うには396倍の拡大が必要でした。最後は、マークの内側からその色を画面いっぱいに広げる必要があります。そして、それは形状でなければなりません(マーク自身のインクの内側から生まれる、輪郭のくっきりした円盤です)。不透明度を徐々に変化させる方法ではいけません。フレームいっぱいの長方形の不透明度を上げると、半透明の色が数フレームにわたって背景に重なり、「画面が青くフェードした」としか認識されません。
リポジトリで注意すること
registry/snap-cn/registry.jsonにフォーマッターを絶対に実行しないでください。 ファイル全体が再整形され、何百行ものノイズに埋もれて1つのエントリーが見えなくなります。pnpm run registry:buildは、対象だけでなくすべてのpublic/r/*.jsonを書き換えます。 ほかのコンポーネントに未コミットのソース変更がある場合、その出力も再生成されます。git statusを確認し、そのことを伝えてください。- デモを再レンダリングしても、再レンダリングされたデモが表示されるとは限りません。
public/demos/<slug>.mp4のファイル名はずっと変わらず、すでにファイルを取得したブラウザーはそのまま再生し続けます。特に<video>は強力にキャッシュします。その結果、すでに存在しないビルドに対して「直っていない」と3回も言われました。現在は正しく修正済みです。render:previewsはすべてのデモをハッシュ化してlib/demo-manifest.jsonに保存し、renderedDemoSrc()はそのハッシュを URL(?v=<hash>)に追加します。そのため、デモを再レンダリングすると別のアドレスになり、キャッシュから古いデモが返されることはありません。また、next.config.tsは開発時に/demos/*に対してno-storeを送信します。 デモを手動で再レンダリングした場合は、必ずrender:previewsを再実行して、マニフェストも更新してください。 古いマニフェストは、古いデモと同じです。そして誰かに「直っていない」と言われたら、相手の言葉を信じる前にも、疑う前にも、ディスク上の内容(md5)とサーバーが実際に配信している内容を確認してください。 - 目測ではなく、リファレンスに合わせてください。 ユーザーからリファレンス動画が提供されたら、フレームを抽出して実際の曲線を測定してください(
ffmpegとインクのしきい値処理を使い、バウンディングボックスを追跡します)。text-revealとtext-swellの振り付けの細部——前方への押し出し、2つの時計、文字の膨らみ、弾む単語——はすべてリファレンスを測定して導き出したもので、どれも肉眼では分からないものでした。