# 用 Astro 讓循環影片可以暫停 — WCAG 2.2.2 淘汰 GIF，iOS 缺硬體解碼淘汰 VP9

> GIF 沒有暫停 API，因此在 WCAG 2.2.2 下不合格。選擇檔案較大的 MP4 而非 VP9，是因為 iOS 沒有 VP9 硬體解碼器。內含編碼流程、守衛與測試記錄。

- Source: https://oharu121.com/zh-tw/blog/astro-looping-video-figure-gif-pause-wcag-vp9-ios/
- Published: 2026-09-02T21:41:49+09:00
- Tags: Astro, Web API, iOS, 無障礙

---
## 引言

下載資料夾裡躺著兩段投影片的螢幕錄影，而原本那篇草稿宣稱 Marp 與 Reveal.js 之間的決定性差異在於投影片內動畫，卻完全沒有把動畫呈現出來。於是我問了一個看似只是格式選擇的問題：一段有播放與暫停按鈕的循環片段，部落格該直接用 `<video>`，還是先轉成 GIF？這個 repo 一開始是否該保存影片檔案，也是同一個問題的一部分。

第一個問題的答案，最後證明不是喜好問題。**GIF 無法暫停**，所以我要求的那顆按鈕，在比較檔案大小之前就已經淘汰了這個格式。第二個問題往下一層也走向同樣的結局：檔案較大的 MP4 打敗了小 35% 的 VP9，原因是 **iOS 沒有 VP9 硬體解碼器**，這一點對會循環播放的片段來說，遠比只看一次的影片重要得多。

本文先說明是無障礙要求決定了容器格式的原因，以及標準的編碼器建議為何不適用於循環內容。接著談編碼流程，以及 Homebrew 的 `ffmpeg` 做不到的兩件事。後半段則沒那麼光彩：三個藏在守衛裡的缺陷，以及兩套回報了尚未成立之結果的測試。

## 前置需求與環境

- macOS（Apple Silicon），本機 Node.js 26
- Astro 7.2、TypeScript 6.0
- 透過 Homebrew 安裝的 `ffmpeg` 8.0
- 這個網站原本就依賴的 `sharp` 0.35
- 現有的 `Figure` 元件，會把這裡的每張圖表與截圖框起來，並在點擊時用 `<dialog>` 開啟

## GIF 為何從一開始就無法勝任

暫停按鈕是需求本身，也是它決定了格式的選擇。

瀏覽器完全不提供操作 GIF 播放的介面。沒有 `pause()`，沒有 `paused`，沒有 `play` 事件，沒有任何東西可以綁在按鈕上。要讓 GIF 停下來，只能換上一張靜態畫面，或是把它畫到 canvas 上重繪，這並不是讓動畫暫停，而是換成另一張圖片。所以我想要的控制項，在這個格式上不只是不方便而已，而是**根本無法實作**。

這讓喜好問題變成了是否合乎規範的問題。WCAG 2.2 達成基準 2.2.2（暫停、停止、隱藏）要求自動開始、且持續超過五秒的動態內容，必須能被使用者暫停、停止或隱藏。這兩段片段都超過五秒。因此 GIF 從**結構上**就不合格，不是程度問題，不管放置方式再怎麼講究都救不回來。

檔案大小只是印證了早就已經決定的結論。GIF 只有 256 色的調色盤，也沒有真正的畫格間預測，因此同一段錄影轉成 GIF 之後，會比 H.264 編碼**大上十到二十倍左右**，畫質卻還更差。

## MP4 為何打敗 VP9，即使違背 web.dev 自己的建議

web.dev 關於取代動畫 GIF 的指引很明確：同時提供 WebM 與 MP4，而且**要把 WebM 列在前面**，因為瀏覽器不會猜測哪個來源比較好，只會採用它支援的第一個。畫質相近時，WebM 裡的 VP9 比 H.264 小約 35%。照這個建議走，這個網站應該替每段片段提供兩個檔案，WebM 排在前面。

**但實際上只送出一個檔案，而且是 MP4。**

| | MP4 / H.264 | WebM / VP9 |
| --- | --- | --- |
| 這兩段片段的大小 | 820 KB，實測 | 約 530 KB，依 35% 推估 |
| iOS 上的硬體解碼 | 有 | **沒有** |
| Safari 的色度限制 | 無 | 僅 4:2:0 或 4:2:2 |
| 版本下限 | 實務上沒有 | iOS 15 |
| 每段片段要保留的檔案數 | 1 | 1，或含備援時 2 |

原因是 **iOS 沒有 VP9 硬體解碼器**。開發者在現行裝置上測試 `VTIsHardwareDecodeSupported(kCMVideoCodecType_VP9)`，得到的回覆都是 `false`，而 WebKit 是自行實作 WebM 解多工器，背後再用軟體解碼 VP9。對只會按一次播放的影片來說，軟體解碼的電力成本付一次就過去了；但對會**只要出現在畫面上就持續循環**的片段來說，這個成本會持續付下去，而這些片段存在的目的正是要放在文章裡反覆播放。

同一輪調查也發現 VP9 另外兩個比較小的限制。Safari 只能解碼色度取樣為 4:2:0 與 4:2:2 的 VP9，因此以 4:4:4 編碼的檔案能在 Firefox 與 Chromium 播放，在 Safari 上卻會無聲無息地失敗，這個問題在代理程式確認時仍然是開著的 issue。`<video>` 裡的 WebM 還有 iOS 15 這道版本下限，在 2026 年幾乎可以忽略，但這是 H.264 完全沒有的限制。

代理程式一開始主張只用 MP4，理由**是錯的**，而且在下一則訊息就自己撤回了。那個理由是從 git 的體積推論而來：二進位檔案的每個版本都會完整儲存、沒有差異壓縮，所以多一種格式就等於讓 repo 永遠多背一份重量。但把數字實際套進這個主張，卻撐不住。二十段片段用 H.264 大約是 5 MB，用 VP9 大約是 3.2 MB，對照 33 MB 的 `.git`，這兩個數字都不重要。**git 的體積本來就不是決定因素，也不該被拿出來當理由**，真正的決定因素是硬體解碼。

## 編碼流程，以及 Homebrew 的 ffmpeg 做不到的兩件事

同一套流程，套用在兩段片段上。

```bash
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 內容寬度的兩倍，讓 Retina 螢幕能對應每個 CSS 像素取得兩個裝置像素。`-2` 把高度四捨五入到偶數，這是 `libx264` 的要求。`-an` 移除音軌：這些片段原本就無聲，沒有音軌的檔案也就不必處理自動播放政策。`+faststart` 把 `moov` atom 移到檔案最前面，讓檔案可以邊下載邊播放，而不必先整個下載完。

Constant Rate Factor（CRF）的數值是實測出來的，不是憑感覺挑的。代理程式分別用 24 與 28 編碼兩段片段，各自取出同一個畫格，以 1:1 比對。在顯示小字比較表的投影片畫面上，兩者看不出差異，而且 **28 小了 29.6%**：較短的片段是 156 KB 對 204 KB，較長的片段是 664 KB 對 960 KB。如果憑感覺選 24，兩個檔案加起來會多付 344 KB。

接著，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
```

這則錯誤讀起來比較像 flag 下錯了，而不是編碼器不見了，這正是它值得記下來的原因。**Homebrew build 根本沒有內建 WebP 編碼器**，而 Homebrew 幾年前就已經取消了逐 formula 的建置選項，所以也沒有 `--with-libwebp` 這種東西可以用。解決方法是先用 `ffmpeg` 把畫格取成 PNG，再用這個網站本來就用來做圖片最佳化的 `sharp` 轉檔。

**這張海報影格值得占一個位置，而且是兩層意義上的值得**，這也是它被納入版本控制、而不是可有可無的原因。自動播放沒有發生時，讀者看到的就是這張圖；而且因為它是以 `ImageMetadata` 匯入的，它的 `width` 與 `height` 會直接套用到 `<video>` 元素上，在片段連一個位元組都還沒載入之前，就先保留好版面的空間。

## 只在讀者看得到的範圍內自動播放

我選擇了先自動播放、再搭配暫停按鈕，而不是先顯示海報影格，所以片段會自己開始播放。讓這個選擇站得住腳的，是圍繞著它的每一項限制。

一個門檻設在 25% 的 `IntersectionObserver`，在片段進入可視區域時播放，離開時暫停。長文章裡兩段片段持續解碼，正是讓自動循環播放難以站得住腳的成本，而這個機制把它拿掉了：**版面下方看不到的地方，什麼都不會解碼**。搭配 `preload="none"`，也什麼都不會下載。頁面上第二段片段在被捲動到之前，`readyState` 一直回報為 `0`。

真正需要動腦筋的地方，是讀者按下暫停之後又捲動畫面時會發生什麼事。控制它的是單一一個布林值，規則是**只有按鈕會寫入這個 flag**。觀察器暫停畫面外的片段時不會動到這個 flag，所以之後還能重新播放；而按下暫停的讀者，等於提出了一個要求，觀察器沒有權限去覆蓋它。

*Figure — PauseOutranksObserver: 兩條路徑都會讓片段暫停，但只有一條能撐過捲出畫面再捲回來的往返。觀察器從不寫入 flag，這正是它能重新播放自己暫停過的片段的原因。*

`prefers-reduced-motion: reduce` 會把同一個 flag 初始化為 `true`，這讓「這位讀者要求減少動態效果」和「這位讀者按下了暫停」變成同一個狀態，兩者都能透過按下播放恢復。而被拒絕的 `play()` 會當成正常情況處理，不是例外，因為 iPhone 的低耗電模式會直接拒絕自動播放。按鈕上的文字是從 `play` 與 `pause` 事件讀來的，而不是根據自己有沒有呼叫成功來決定，所以被拒絕時畫面仍然顯示「播放」，而這正是事實。

## 撞上自己控制項的守衛

影片需要一個現有 `Figure` 元件沒有的控制項，同時也得放棄它原本有的一個。放大對話框會把 `<svg>` 或 `<img>` 複製進 `<dialog>` 裡；但用這種方式複製片段會遺失播放進度，所以影片 figure 用全螢幕按鈕取代放大鏡。

第一次嘗試抑制放大鏡的作法，看起來理所當然。對話框本來就會判斷自己的媒體內容，按鈕似乎可以套用同一個判斷。

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

**結果什麼都沒擋住**。影片 figure 上的放大鏡又跑了回來，點下去會在**播放三角形**上打開對話框，因為新元件裡的播放與暫停圖示本身*就是* `<svg>` 元素，而那個選擇器找到的正是它們。

*Figure — FrameSubtree: 選擇器是對整個 frame 執行的，而 frame 裡面就包含了放大鏡按鈕。任何帶著自己圖示的子元素，都會滿足這個原本是為了描述 figure 媒體內容而寫的測試。*

解決方法是不再用猜的，改讓子元素自己宣告。影片 figure 設定 `data-figure-owns-controls`，按鈕的腳本一看到這個屬性就會直接返回。**選擇退出是在表明意圖，偵測媒體內容只是在猜測意圖**，而只要子樹之後多了任何新標記，這個猜測就會被打破。

## 永遠不會觸發的守衛

`svg, img` 這個判斷仍然留在原地，當作第二道檢查，理論上還能涵蓋任何包著非 svg、非 img 內容的 figure。發布前的程式碼審查卻發現它**根本不可能被觸發**。

放大鏡按鈕本身就在被搜尋的那個元素裡面，而且它會畫出一個圖示。所以對這個 frame 執行的 `querySelector("svg, img")` 永遠會找到東西，不可能回傳 `null`。代理程式當初為了處理某種情況寫下的那一行程式碼，以及 CHANGELOG 裡描述已修復的那條紀錄，講的都是一個根本不會被執行到的分支。

這個判斷原本要抓的那個缺陷其實還在，而且形狀跟 CHANGELOG 說的不一樣。包著表格的 figure 按下按鈕之後，會在放大鏡圖示本身上打開對話框，而且以整個寬度呈現。這不是 CHANGELOG 說的「什麼都不做的控制項」，**而是一個會做出荒謬結果的控制項**。

```ts
#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 圖片包進一個段落裡。沒有任何 `:scope >` 的寫法能碰到那一層。

## 全螢幕時以實際尺寸繪製

這個問題是在量測另一件事時浮現的。我當初問的是點擊影片外側是否應該離開全螢幕，就像圖表對話框點擊背景就會關閉那樣，於是代理程式去量測一段長寬比 1.94:1 的片段，到底有多少「外側」可言。

量測結果帶回了黑邊的尺寸，還有一個沒人特別在找的數字：在 1920×1080 的螢幕上，片段是以自己儲存時的尺寸 1324×682 繪製的，留下**螢幕 56.5% 是空白**。

*Figure — FullscreenSizing: 對照片段在內文中原本就有的 686px，故障的控制項只放大到 1.9 倍，修好之後則放大到 2.8 倍，而這項功能唯一的目的就是讓人看得清楚小字。*

原因是 `<video>` 上的 `width: auto` 會解析成檔案本身的原始尺寸，而 `max-width: 100%` 只會往縮小的方向限制。這裡設置全螢幕的目的，是讓讀者看得清 10px 大小的投影片文字，而 1920px 寬的螢幕上只用到 1324px，跟片段在內文裡原本就有的 686px 幾乎沒有差多少。

修法是依海報影格本身的尺寸算出比例，再拿這個比例來決定大小，這樣數值就不可能跟檔案本身脫節。

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

這刻意跟同一個 repo 套用在點陣圖片上的規則相反：放大對話框拒絕把截圖畫得比原始寬度更大，因為放大只是內插而已。差別在於讀者當下在做什麼。圖表對話框是給人檢視細節用的，還想看更清楚的讀者可以再用瀏覽器縮放。片段則是在內文以一半尺寸播放中被觀看，沒有其他方式能看到更大的畫面，所以**畫面上的視覺大小，勝過每個像素的銳利度**。所有出過貨的影片播放器，最後都走到了同一個結論。

這裡也有一個看起來很誘人、實際上是錯的修法。在滿版的區塊上使用 `object-fit: contain` 看起來等價，卻會悄悄拿掉我原本要求的功能：元素本身會蓋滿整個螢幕，黑邊會變成 `<video>` 的一部分，點擊畫面外側就再也沒有可以落地的地方。

## 兩套回報錯誤結果的測試

最先寫的兩套驗證機制都錯了，而且方向相反。

*Figure — TestsThatLied: 同一個工作階段裡出現了一次假的通過和一次假的失敗。前者檢查的性質比真正重要的那個還弱；後者量測的物件，其實早就繼承了現成的答案。*

假的通過，來自檢查錯了性質。JavaScript 停用時，所有控制項理論上都該維持隱藏，而那次檢查驗證的是每顆按鈕上是否有 `hasAttribute("hidden")`。四顆按鈕都回傳 `true`，執行結果就被回報為已驗證。但元件自己的 CSS 寫著沒有 `:not([hidden])` 限定的 `.badge { display: grid }`，而 **author 端的宣告會勝過使用者代理樣式表的 `[hidden] { display: none }`**。hidden 屬性確實存在，卻被忽略了。任何一個腳本沒有執行的頁面，海報影格上都會疊出四顆失效的控制項，而那次測試驗證的是屬性本身，而不是實際結果。現有的 `Figure` 元件正是為了這個理由才寫了 `.figzoom-badge:not([hidden])`，註解裡也寫明了原因。

接著出現的是假的失敗，出在用來證明 `#media()` 這項修法有效的測試裡。這套測試複製了一個已經完成 hydration 的元素，拿掉媒體內容再接回去，然後檢查按鈕是否仍維持隱藏。結果並沒有，執行結果回報守衛已損壞。**但守衛其實是正常的**。複製一個自訂元素時，建構子是在一個空節點上執行的，因此欄位初始化找不到任何東西，`connectedCallback` 會提早返回、什麼都不改；而複製出來的元素，卻帶著從原始元素繼承來的 `hidden = false` 與 `data-ready`。

讓重新測試值得信任的，是**在旁邊跑了一組正常案例**。這套測試用 `insertAdjacentHTML` 這條解析器路徑組出元素，讓子元素在元素升級之前就已經存在，跟伺服器算繪的頁面完全一樣。它還重設了 hydration 動過的那兩個屬性，並且把媒體存在的案例，跟媒體不存在的案例並排執行，因為唯有這一對案例合在一起才算得上證據。

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

**frame 裡有兩個 svg，代表圖表加上放大鏡都在，按鈕就會出現；只有一個 svg，代表只剩放大鏡，按鈕就不會出現**。沒有正常案例的守衛測試，**分辨不出運作正常的守衛跟壞掉的測試機制**，而這次工作階段裡，兩種失敗模式各發生了一次。

## 無法驗證、也就這樣留著的部分

**有三件事沒能驗證**。老實寫出來，比裝作驗證過還划算。

**按 Escape 離開全螢幕**。這個綁定存在於瀏覽器的介面層，而不是頁面裡，CDP 注入的 `Escape` 碰不到它。不管是無頭 Chromium，還是透過 DevTools 協定操控的真實 Chrome 都一樣。這是這個元件既沒有實作、也不可能弄壞的標準瀏覽器行為，但在這裡從來沒有真的觀察到它運作過。一個按下 Escape 就假設它生效了的測試，會讓頁面停留在全螢幕狀態，之後某次執行就因此對一個無關的元素報出令人困惑的 `subtree intercepts pointer events` 而失敗。

**Vercel 的預覽環境**。它位在部署保護之後，無頭的 fetch 會被導向登入頁面，只有已登入的瀏覽器才能開啟它。

**正式環境裡的片段**。承載這些片段的文章目前仍是 `status: 'draft'`，`astro build` 會略過它，因此就算建置成功，也完全沒有驗證到這一塊。真正送進正式環境的變更，是每個放大 figure 共用的關閉控制項，而這其實才是風險較高的那一半：影響的是既有的 86 個 figure，而不是一篇草稿。

## 總結

容器格式的決定，靠的是無障礙要求，不是喜好。GIF 沒有暫停 API，所以 WCAG 2.2 達成基準 2.2.2 排除了任何超過五秒的片段使用這個格式，而這個結論在檔案大小進入討論之前就已經確定。

編碼器的選擇則違背了標準建議。web.dev 說要先用 WebM、再以 MP4 備援，但對於在畫面上循環播放的片段，iOS 缺少 VP9 硬體解碼這件事，把這個建議整個翻轉過來。**這個建議本身是對的，只是適用範圍比看起來窄**。只看一次的影片跟循環播放的 figure，不是同一種傳輸問題。

這次工作階段剩下的部分，是一堂關於缺陷實際藏在哪裡的課。其中三個都出在原本用來防止缺陷的程式碼裡：一個守衛被沒料到的圖示打敗，同一個守衛在變得不可能觸發之後仍然留在原地，還有一條全螢幕規則本該設定尺寸，結果卻只是在限制尺寸。在這些被抓到之前，還有兩套測試機制回報了錯誤的結果，一個沒有根據就通過，另一個沒有缺陷卻失敗。實測編碼省下了 344 KB；而實測守衛，才是把三個看似合理的修法變成三個真正管用的修法的關鍵。

## 參考連結

- [讓頁面讀取更快：用影片取代動畫 GIF，也是「先 WebM、兩種格式並存」建議的出處](https://web.dev/articles/replace-gifs-with-videos)
- [不只是圖片：Web 上的基本影片處理](https://web.dev/articles/video-basics)
- [WCAG 2.2 達成基準 2.2.2 暫停、停止、隱藏](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html)
- [Apple Developer Forums 討論串：iOS 上 VTIsHardwareDecodeSupported 對 VP9 回傳 false](https://developer.apple.com/forums/thread/664770)
- [caniuse issue 7541，仍未關閉，關於 Safari 無法在 4:4:4 色度下解碼 VP9](https://github.com/Fyrd/caniuse/issues/7541)
- [WebM 影片格式支援情況表，包含 iOS 15 這道版本下限](https://caniuse.com/webm)
