Astroブログでループ動画を一時停止可能にする — WCAG 2.2.2でGIFを、iOSのハードウェア非対応でVP9を退けた

GIFには一時停止APIがなく、WCAG 2.2.2で不適合になった。VP9より大きいMP4を選んだのはiOSにVP9のハードウェアデコーダーがないため。エンコード手順とガード、テストの記録。

黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ
目次

はじめに

ダウンロードフォルダには2本のスライド画面録画が残っていた。もとの下書き記事では、MarpとReveal.jsの決定的な違いはスライド内アニメーションだと主張していたのに、そのアニメーションを1枚も見せていなかった。そこで一見ただの表示形式の質問を投げた。再生・一時停止ボタン付きのループクリップは、ブログで<video>をそのまま使うべきか、それともGIFに変換すべきか。そもそも動画ファイルをこのリポジトリで持つべきなのか、という問いも一緒だった。

最初の問いへの答えは、好みの問題ではなかった。GIFは一時停止できないため、自分が求めたボタンによって、ファイルサイズを比較するより前にこのフォーマットは脱落した。2つ目の問いも一段階下で同じ結末をたどった。35%小さいVP9より重いMP4を選んだのは、iOSにVP9のハードウェアデコーダーがないからで、これは一度だけ再生する動画よりループするクリップにとってはるかに重要な違いだった。

この記事ではまず、アクセシビリティ要件がコンテナ形式を決めた理由と、コーデックに関する定番の助言がループコンテンツには当てはまらない理由を扱う。続けてエンコードのレシピと、Homebrewのffmpegができない2つのことを説明する。後半はやや格好悪い話だ。ガードの中に潜んでいた3つのバグと、まだ証明していない結果を報告していた2つのテストを記録する。

前提条件と環境

  • macOS(Apple Silicon)、ローカルはNode.js 26
  • Astro 7.2、TypeScript 6.0
  • Homebrew経由のffmpeg 8.0
  • このサイトが既に依存しているsharp 0.35
  • このサイトのすべての図版とスクリーンショットを囲み、クリックで<dialog>に開く既存のFigureコンポーネント

GIFではそもそも成立しなかった理由

一時停止ボタンが要件であり、それがフォーマットを決めた。

ブラウザはGIFの再生に対して一切の操作手段を公開していない。pause()もpausedもplayイベントもなく、ボタンを結びつける先がない。GIFを止めるには静止フレームに差し替えるか、キャンバスに焼き付けて描き直すしかなく、それはアニメーションを一時停止しているのではなく、別の画像に置き換えているにすぎない。つまり自分が求めたコントロールは、このフォーマットでは扱いにくいという程度の話ではなかった。実装そのものが不可能だった。

これによって好みの問題が、達成基準を満たすかどうかの問題に変わる。WCAG 2.2の達成基準2.2.2(一時停止、停止、非表示)は、自動的に開始し5秒を超えて動き続けるコンテンツについて、利用者が一時停止・停止・非表示にできることを求めている。今回の2本のクリップはどちらも5秒を超える。したがってGIFは程度の問題ではなく構造上この基準を満たせず、配置の工夫で救うこともできない。

ファイルサイズは、既に決まっていた結論を裏付けたにすぎない。GIFは256色パレットしか持たず、フレーム間予測もまともに行わないため、同じ録画をGIF化するとH.264エンコードよりおおよそ10倍から20倍大きくなり、それでいて見た目も劣る。

web.dev自身の助言に反してMP4がVP9に勝った理由

アニメーションGIFを置き換える際のweb.devのガイドラインは明確だ。WebMとMP4の両方を用意し、WebMを先に書くべきだとしている。ブラウザはどのソースが最適かを推測せず、対応している最初の1つを採用するからだ。同じ画質であればWebM内のVP9はH.264よりおおよそ35%小さい。この助言に従うなら、このサイトはクリップごとに2ファイルを、WebMを先頭にして配信すべきということになる。

実際に配信するのは1ファイルだけで、それはMP4だ。

MP4 / H.264WebM / VP9
このクリップ2本のサイズ820 KB(実測)約530 KB(35%から推定)
iOSでのハードウェアデコードありなし
Safariのクロマ制約なし4:2:0または4:2:2のみ
バージョンの下限実質なしiOS 15
クリップごとの保持ファイル数11、またはフォールバック込みで2

理由はiOSにVP9のハードウェアデコーダーがないことだ。現行デバイスでVTIsHardwareDecodeSupported(kCMVideoCodecType_VP9)を試した開発者はfalseを返されており、WebKitは自前のWebMデマルチプレクサに、その裏でソフトウェアVP9デコードを実装している。1回だけ再生する動画であれば、ソフトウェアデコードのバッテリーコストは払って終わりだ。しかし画面に映っている間ずっとループするクリップでは、そのコストを継続的に払い続けることになる。そして今回のクリップはまさに、記事の中に置かれて繰り返し再生されるために存在している。

同じ調査からVP9のより小さな制約も2つ見つかった。Safariが対応するVP9のクロマサブサンプリングは4:2:0と4:2:2のみで、4:4:4でエンコードするとFirefoxとChromiumでは再生できてもSafariでは無音で失敗する。この問題はエージェントが確認した時点でもまだ未解決のままissueが開いていた。<video>内のWebMにはさらにiOS 15という下限があり、2026年時点では実質無視できる制約だが、H.264にはそもそも存在しない下限でもある。

エージェントが最初にMP4単独を主張した根拠は間違っていたもので、1メッセージ後に自ら撤回している。その根拠はgitの重さからの推論だった。バイナリはリビジョンごとに差分なしで丸ごと保存されるため、フォーマットを1つ増やせばリポジトリが永久に抱える容量が倍になる、という理屈だ。しかしその主張に実際の数字を当てはめると、根拠にならなかった。20本のクリップはH.264ならおよそ5MB、VP9ならおよそ3.2MBで、.git全体33MBに対してはどちらの数字も意味を持たない。gitの重さは決め手ではなく、決め手として持ち出すべきではなかった。決め手はハードウェアデコードの有無だった。

エンコードと、Homebrewのffmpegができない2つのこと

レシピは1つで、両方のクリップに適用した。

ターミナルウィンドウ
ffmpeg -i raw.mov -vf scale=1324:-2 \
-c:v libx264 -crf 28 -preset slow \
-pix_fmt yuv420p -movflags +faststart -an \
clip.mp4

1324は本文カラムのコンテンツ幅662pxの2倍で、Retinaディスプレイでは1CSSピクセルあたり2デバイスピクセルになる。-2は高さを偶数に丸めるための指定で、libx264が要求する。-anは音声を落とす指定だ。今回のクリップはもともと無音で、音声トラックがなければ自動再生ポリシーを気にする必要もなくなる。+faststartはmoovアトムをファイルの先頭に移動させ、全体をダウンロードしきる前にストリーミング再生できるようにする。

Constant Rate Factor(CRF)の値は勘ではなく計測で決めた。エージェントは両方のクリップを24と28でそれぞれエンコードし、同じフレームを取り出して1:1で見比べた。小さな文字で比較表を表示しているスライドでは両者を見分けられず、しかも28は29.6%小さかった。短いクリップは156KB対204KB、長いクリップは664KB対960KBだった。もし24を勘で選んでいたら、2ファイル合計で344KB余分にかかっていたことになる。

続けて、Homebrewのffmpegはポスター画像の生成を拒んだ。

[vost#0:0 @ 0xc54c24300] Unknown encoder 'libwebp'
[vost#0:0 @ 0xc54c24300] Error selecting an encoder
Error opening output file .../marp-demo-poster.webp
Error opening output files: Encoder not found

このエラーは一見、コーデックが足りないというよりフラグの指定ミスのように読める。それこそが書き残す価値のある点だ。Homebrewのビルドには、そもそもWebPエンコーダーが組み込まれていない。しかもHomebrewは数年前にformulaごとのビルドオプションを廃止しているため、--with-libwebpのような指定手段も存在しない。解決策はffmpegでフレームをPNGとして取り出し、このサイトが画像最適化に既に使っているsharpで変換することだった。

このポスター画像は二重に役立っており、あれば嬉しい程度のものではなく、コミット対象のファイルとして扱う理由もそこにある。自動再生が起きなかったときに読者が目にするのはこの画像であり、ImageMetadataとしてインポートされているため、そのwidthとheightが<video>要素にそのまま渡り、クリップが1バイトも読み込まれる前にレイアウト用の領域を確保してくれる。

読者に見えている範囲に絞った自動再生

ポスター表示を先にする案ではなく、一時停止ボタン付きの自動再生を選んだ。つまりクリップは自分から再生を始める。それを正当化しているのは、その自動再生を縛っている条件のすべてだ。

しきい値25%のIntersectionObserverが、クリップがビューポートに入ったら再生し、外れたら一時停止する。長い記事の中で2本のクリップが常時デコードされ続けることこそ、自動再生ループを正当化しづらくするコストであり、この仕組みがそれを取り除いている。折り返しより下では何もデコードされない。preload="none"と組み合わせているため、ダウンロードも発生しない。ページ2本目のクリップは、スクロールで表示されるまでreadyState: 0のままだった。

実際に頭を使ったのは、読者が一時停止を押してからスクロールしたときの挙動だ。制御しているのは1つの真偽値で、ルールはそのフラグを書き換えるのはバッジだけというものだ。オブザーバーは画面外に出たクリップを一時停止するがフラグには触れない。だから後で再開できる。一方、一時停止を押した読者は何かを要求しており、オブザーバーがそれを上書きすることは許されない。

オブザーバーによる停止は戻ると再開し、読者による停止は再開しない同じ 3 つの状態を通る 2 本の流れ。上の流れでは、画面外に出た動画をオブザーバーが停止するが userPaused フラグは書き換えないため、戻ってくると再生が再開する。下の流れでは読者がボタンを押して userPaused を true にするため、画面外に出て戻ってきても停止したままになる。下部の原則は、フラグを書くのはボタンだけで、オブザーバーは読むだけ、と述べている。オブザーバーが止めた場合再生中画面外へスクロールフラグは触らない一時停止戻ってくる再生中再生を再開読者が止めた場合再生中画面外へスクロールuserPaused = true一時停止戻ってくる一時停止停止したままフラグを書くのはボタンだけ。オブザーバーは読むだけ。

どちらの経路もクリップを一時停止させるが、画面外に出て戻ってくる往復を生き延びるのは片方だけだ。オブザーバーは決してフラグを書き換えないからこそ、自分が一時停止したクリップを後で再開できる。

prefers-reduced-motion: reduceはこの同じフラグをtrueで初期化する。これによって「この読者はモーションを減らしてほしいと望んでいる」状態と「この読者が一時停止を押した」状態は同じ1つの状態になり、どちらも再生ボタンを押せば元に戻る。また拒否されたplay()は例外ではなく通常の結果として扱う。iPhoneの低電力モードは自動再生をそのまま拒否するからだ。バッジのラベルは、自分が呼び出しをしたかどうかではなくplayイベントとpauseイベントから決まるため、拒否された場合はそのまま「再生」と表示され続ける。それが事実だからだ。

自分自身のUIにマッチしてしまったガード

動画には既存のFigureコンポーネントが持っていないコントロールが必要で、逆に持っているコントロールの1つを手放す必要もあった。拡大ダイアログは<svg>や<img>を<dialog>に複製するが、クリップをその方法で複製すると再生位置が失われてしまう。そのため動画figureは拡大鏡の代わりにフルスクリーンバッジを持つ。

拡大鏡を抑制する最初の試みは、一見わかりやすいものだった。ダイアログは既に自分のメディアを判定していたので、バッジも同じ判定を使えばよいはずだった。

if (!this.#frame.querySelector("svg, img")) return;

これは何も効かなかった。動画figureにも拡大鏡が復活し、クリックすると再生ボタンの三角形の上にダイアログが開いてしまうところだった。新しいコンポーネントの再生・一時停止アイコンは実際に<svg>要素であり、あのセレクタはそのsvgを見つけてしまっていたからだ。

検索対象の要素の中に、検索している側のボタンが入っているインデントで表した木構造。figure 要素の中に figure-zoom があり、その中に class が frame の div がある。querySelector はこの frame に対して実行される。frame の中には子が 2 つ並ぶ。図版そのものを差し込む slot と、アイコンが svg でできた拡大ボタンである。ボタン内部の svg にセレクタが最初にマッチする印が付いており、この判定が null を返すことはない。figurefigure-zoom.framequerySelector はここで実行される<slot />図版そのもの<p> → <img>button.figzoom-badge拡大ボタン<svg>最初にマッチするつまりこの判定が null を返すことはない。

セレクタはframe全体に対して実行され、そのframeの中には拡大鏡ボタンも含まれている。自分自身のアイコンを持つ子要素であれば何であれ、本来figureのメディアを判定するはずだったテストを満たしてしまう。

解決策は、探るのをやめて子要素自身に宣言させることだった。動画figureはdata-figure-owns-controls属性を持ち、バッジ側のスクリプトはこの属性を見つけると処理を打ち切る。オプトアウトは意図を明示するが、メディアを探る方法は意図を推測するだけであり、その推測はサブツリーに何か新しいマークアップが加わるたびに裏切られる。

発火しえないガード

svg, imgによる判定は第2のチェックとしてそのまま残されていた。svgでもimgでもない何かを包むfigureにはまだ効くはずだという想定だった。しかしリリース時のコードレビューで、この判定は到達不可能であることが判明した。

拡大鏡ボタンは、検索対象そのものの要素の中に存在し、しかもアイコンを描画している。そのためframeに対するquerySelector("svg, img")は必ず何かにマッチし、nullを返すことは決してない。エージェントがあるケースを捕まえるために書いた行も、それを「修正済み」と記述したCHANGELOGの記述も、どちらも実行され得ない分岐について書いていたことになる。

この判定が捕まえるはずだったバグ自体も残っており、しかもCHANGELOGが主張していたのとは違う形をしていた。テーブルを包むfigureでは、バッジを押すと拡大鏡のアイコン自体が全幅で描かれたダイアログが開いてしまう。CHANGELOGが言うような「何もしないコントロール」ではなかった。むしろ、おかしなことをするコントロールだった。

#media(): SVGSVGElement | HTMLImageElement | null {
const candidates =
this.#frame?.querySelectorAll<SVGSVGElement | HTMLImageElement>("svg, img") ?? [];
for (const candidate of candidates) {
if (!this.#badge?.contains(candidate)) return candidate;
}
return null;
}

svg:not(.figzoom-badge svg)のようなCSSではなくスクリプト側で絞り込んでいるのには具体的な理由がある。記事内のラスター画像は.frame > p > imgという位置にあり、remarkが単独のMarkdown画像を<p>で包んでしまうからだ。どんな:scope >の書き方でもそこには届かない。

フルスクリーンが実サイズのまま描画されていた

この不具合は、別のことを計測している最中に見つかった。図版ダイアログが背景クリックで閉じるのと同じように、動画の外側をクリックしたらフルスクリーンを終了すべきかを尋ねたところ、エージェントは1.94:1のクリップに対して実際にどれだけの「外側」があるのかを計測しに行った。

戻ってきた計測結果には、レターボックスの寸法に加えて誰も探していなかった数字が含まれていた。1920×1080の画面上でクリップは保存時のサイズそのままである1324×682で描画されており、画面の56.5%が空白のままだった。

全画面表示で実サイズのまま描かれた動画と、画面を埋めた動画同じ縮尺で並べた 1920 × 1080 の画面が 2 つ。左は width: auto で、動画が中央に 1324 × 682 で置かれ、周囲の 56.5% が余白になっている。右は動画自身のアスペクト比を使って寸法を決めており、1920 × 989 で横幅いっぱいに広がり、上下に細い帯だけが残る。width: autoファイル自身のサイズになる1324 × 6821920 × 1080 の画面56.5% が余白width: min(100vw, 100vh × 比率)先に効く軸いっぱいまで広がる1920 × 9891920 × 1080 の画面横幅いっぱい

クリップがインライン表示で既に持っていた686pxと比べると、壊れていたコントロールは1.9倍にしか広げていなかったのに対し、修正後は2.8倍まで広げる。小さな文字を読むことだけが目的の機能であることを踏まえると、この差は大きい。

原因は、<video>に対するwidth: autoがファイルの実サイズに解決されてしまうことであり、max-width: 100%は縮小方向にしか制約をかけない。ここでフルスクリーンを用意しているのは読者が10pxのスライド文字を読めるようにするためであり、1920px幅の画面の中の1324pxでは、クリップがインラインで既に持っていた686pxとほとんど変わらない。

修正では、ポスター画像自身の寸法から取った比率を基準にサイズを決めるため、この数値がファイルの実態からずれることはない。

video-figure:fullscreen video {
width: min(100vw, calc(100vh * var(--video-ratio)));
max-width: 100vw;
height: auto;
max-height: 100vh;
}

これは同じリポジトリがラスター画像に適用しているルールとあえて逆にしている。拡大ダイアログはスクリーンショットを実サイズより大きく描画することを拒否する。拡大は補間にすぎないからだ。違いは読者が何をしているかにある。図版ダイアログは細部を確認するためのものであり、それでも足りない読者はブラウザ側でさらにズームできる。一方クリップはインラインで半分のサイズのまま視聴されており、他に大きく見る手段がない。だからこそ画面上での見かけの大きさが、1ピクセルあたりの精細さよりも優先される。これまでに出荷されたあらゆる動画プレーヤーが、同じ結論にたどり着いている。

ここにも誘惑的だが間違った修正案があった。全画面いっぱいのボックスにobject-fit: containを使う案は一見同等に見えるが、実際には自分が求めていた機能を静かに壊してしまう。要素自体が画面全体を覆うことになり、レターボックスは<video>の一部になってしまい、画像の外側をクリックしても着地する場所がなくなる。

間違った結果を報告した2つのテスト

最初に書いた2つの検証ハーネスは、どちらも間違った結果を報告していた。しかも方向が逆だった。

根拠なく合格した検証と、不具合なしに失敗した検証2 つの列。左は「誤った合格」で、4 つのボタンに hidden 属性があることを検証していた。実際には作者側の display: grid が優先されて 4 つとも表示されていたが、実行結果は「検証済み」と報告された。右は「誤った失敗」で、複製した要素の hidden が false のままであることを検証していた。実際にはガードは正常に動いており、複製が空の状態でカスタム要素に昇格していただけだが、実行結果は「壊れている」と報告された。下部の対策は、結果そのものを検証し、正常系を並べて走らせること。誤った合格何を検証したかhasAttribute("hidden")を 4 つのボタンで確認実際はどうだったかdisplay: grid が勝ち4 つとも表示されていた「検証済み」と報告誤った失敗何を検証したか複製した要素でhidden = false のまま実際はどうだったかガードは正常。複製が空の状態で昇格していた「壊れている」と報告どちらの対策も同じ。結果そのものを検証し、正常系を並べて走らせる。

誤った合格と誤った失敗が、同じセッションから生まれた。前者は本来重要な性質より弱い性質を検証しており、後者はすでに答えを引き継いでしまったオブジェクトを測っていた。

誤った合格は、検証すべき性質を取り違えたことから生まれた。JavaScriptが無効な状態では、すべてのコントロールが非表示のままであるはずで、そのチェックは各バッジに対してhasAttribute("hidden")を検証していた。4つともtrueが返り、実行結果は検証済みと報告された。しかしコンポーネント自身のCSSには:not([hidden])の限定なしで.badge { display: grid }とだけ書かれていた。author側の宣言はユーザーエージェントスタイルシートの[hidden] { display: none }に勝つ。hidden属性は存在していたが無視されていたのだ。スクリプトが一度も走らなかったページ上には、ポスター画像の上に4つの死んだコントロールが描画されており、そのテストは結果ではなく属性の存在だけを確認していたことになる。既存のFigureコンポーネントがまさにこの理由で.figzoom-badge:not([hidden])を書いており、コメントにもその旨が記されている。

次に起きたのが誤った失敗で、#media()の修正が効いていることを証明するために書いたテストだった。このテストは既にハイドレーション済みの要素を複製し、メディアを取り除いてから追加し、バッジが非表示のままかどうかを検証していた。非表示のままではなく、実行結果は「ガードが壊れている」と報告した。しかしガードは正常だった。カスタム要素を複製すると、そのコンストラクタは空のノードに対して実行されるため、フィールドの初期化処理は何も見つけられず、connectedCallbackは何も変更しないまま早期リターンする。一方で複製された要素には、コピー元から引き継いだhidden = falseとdata-readyがそのまま残っていた。

再テストを信頼できるものにしたのは正常系を並べて走らせたことだった。この再テストはinsertAdjacentHTMLというパーサー経由の方法で要素を組み立てるため、要素がアップグレードされる前に子要素が存在しており、サーバーレンダリングされたページとまったく同じ状況になる。さらにハイドレーションが変更していた2つの属性をリセットし、メディアがない場合のケースと並べてメディアがある場合のケースも実行する。証拠になるのはこのペアだけだからだ。

positive control (media present): {"badgeHidden":false,"frameReady":true,"svgsInFrame":2}
guard under test (no media): {"badgeHidden":true,"frameReady":false,"svgsInFrame":1}

frame内にsvgが2つあれば図版と拡大鏡の両方が揃っており、バッジは表示される。svgが1つだけなら拡大鏡しかなく、バッジは表示されない。正常系を伴わないガードのテストでは、正常に動くガードと壊れたテストハーネスを区別できない。今回のセッションでは、この2つの失敗パターンがそれぞれ1回ずつ実際に起きた。

検証できず、そのままになったこと

確認できなかったことが3つある。それを正直に書いておくほうが、確認済みであるかのように装うより安上がりだ。

Escapeキーでフルスクリーンを終了する動作。このバインディングはページではなくブラウザのUI側に存在しており、CDP経由で注入したEscapeはそこに届かない。ヘッドレスChromiumでも、DevToolsプロトコル経由で操作する実際のChromeでも同様だ。これはこのコンポーネントが実装しているわけでも壊しうるわけでもない標準的なブラウザの挙動だが、今回一度も動作しているところを確認できてはいない。Escapeを押して動作したものとみなしてしまうテストは、ページをフルスクリーンのままにしてしまい、後続の実行で無関係な要素に対するsubtree intercepts pointer eventsという紛らわしいエラーで失敗する原因になった。

Vercelのプレビュー環境。デプロイ保護の背後にあるため、ヘッドレスでのfetchはログインページにリダイレクトされ、サインイン済みのブラウザからしか開けない。

本番環境でのクリップ。クリップを含む記事はまだstatus: 'draft'のままで、astro buildはこれをスキップするため、ビルドが通ってもこの部分は一切検証されない。実際に本番に届いた変更は、すべての拡大figureに共通の閉じるコントロールであり、そちらのほうがリスクとしては大きい半分でもある。下書き1本ではなく既存の86個のfigureに影響するからだ。

まとめ

コンテナ形式を決めたのは好みではなくアクセシビリティ要件だった。GIFには一時停止APIがないため、WCAG 2.2の達成基準2.2.2は5秒を超えるすべてのクリップに対してこのフォーマットを排除する。ファイルサイズが議論に入ってくるより前に、フォーマットは決まっていたことになる。

コーデックの選択は、定番の推奨とは逆になった。web.devはWebMを先に、MP4をフォールバックにと言うが、画面上でループするクリップに関してはiOSでのVP9ハードウェアデコード非対応がこの助言を裏返してしまう。その助言自体は正しく、ただし想定している範囲は見た目より狭い。1回きり視聴する動画とループするfigureは、配信の問題として同じではない。

セッションの残りは、バグが実際にどこに潜んでいるかについての教訓だった。3件はいずれもバグを防ぐために書いたコードの中にあった。想定していなかったアイコンに裏をかかれたガード、到達不可能になった後も残されたままだった同じガード、本来サイズを設定すべきところをただ制約するだけになっていたフルスクリーンのルールだ。これらが見つかるより前に、2つのテストハーネスが間違った結果を報告しており、一方は根拠なく合格し、もう一方は不具合がないのに失敗していた。エンコードを計測したことで344KBを節約できた。そしてガードを計測したことこそが、3つのもっともらしい修正を3つの本物の修正に変えた。

参考リンク

この記事をシェア