用 Astro 讓循環影片可以暫停 — WCAG 2.2.2 淘汰 GIF,iOS 缺硬體解碼淘汰 VP9
GIF 沒有暫停 API,因此在 WCAG 2.2.2 下不合格。選擇檔案較大的 MP4 而非 VP9,是因為 iOS 沒有 VP9 硬體解碼器。內含編碼流程、守衛與測試記錄。

本頁目錄
引言
下載資料夾裡躺著兩段投影片的螢幕錄影,而原本那篇草稿宣稱 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 安裝的
ffmpeg8.0 - 這個網站原本就依賴的
sharp0.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 做不到的兩件事
同一套流程,套用在兩段片段上。
ffmpeg -i raw.mov -vf scale=1324:-2 \ -c:v libx264 -crf 28 -preset slow \ -pix_fmt yuv420p -movflags +faststart -an \ clip.mp41324 是內文欄 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 encoderError opening output file .../marp-demo-poster.webpError 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,所以之後還能重新播放;而按下暫停的讀者,等於提出了一個要求,觀察器沒有權限去覆蓋它。
兩條路徑都會讓片段暫停,但只有一條能撐過捲出畫面再捲回來的往返。觀察器從不寫入 flag,這正是它能重新播放自己暫停過的片段的原因。
prefers-reduced-motion: reduce 會把同一個 flag 初始化為 true,這讓「這位讀者要求減少動態效果」和「這位讀者按下了暫停」變成同一個狀態,兩者都能透過按下播放恢復。而被拒絕的 play() 會當成正常情況處理,不是例外,因為 iPhone 的低耗電模式會直接拒絕自動播放。按鈕上的文字是從 play 與 pause 事件讀來的,而不是根據自己有沒有呼叫成功來決定,所以被拒絕時畫面仍然顯示「播放」,而這正是事實。
撞上自己控制項的守衛
影片需要一個現有 Figure 元件沒有的控制項,同時也得放棄它原本有的一個。放大對話框會把 <svg> 或 <img> 複製進 <dialog> 裡;但用這種方式複製片段會遺失播放進度,所以影片 figure 用全螢幕按鈕取代放大鏡。
第一次嘗試抑制放大鏡的作法,看起來理所當然。對話框本來就會判斷自己的媒體內容,按鈕似乎可以套用同一個判斷。
if (!this.#frame.querySelector("svg, img")) return;結果什麼都沒擋住。影片 figure 上的放大鏡又跑了回來,點下去會在播放三角形上打開對話框,因為新元件裡的播放與暫停圖示本身就是 <svg> 元素,而那個選擇器找到的正是它們。
選擇器是對整個 frame 執行的,而 frame 裡面就包含了放大鏡按鈕。任何帶著自己圖示的子元素,都會滿足這個原本是為了描述 figure 媒體內容而寫的測試。
解決方法是不再用猜的,改讓子元素自己宣告。影片 figure 設定 data-figure-owns-controls,按鈕的腳本一看到這個屬性就會直接返回。選擇退出是在表明意圖,偵測媒體內容只是在猜測意圖,而只要子樹之後多了任何新標記,這個猜測就會被打破。
永遠不會觸發的守衛
svg, img 這個判斷仍然留在原地,當作第二道檢查,理論上還能涵蓋任何包著非 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 圖片包進一個段落裡。沒有任何 :scope > 的寫法能碰到那一層。
全螢幕時以實際尺寸繪製
這個問題是在量測另一件事時浮現的。我當初問的是點擊影片外側是否應該離開全螢幕,就像圖表對話框點擊背景就會關閉那樣,於是代理程式去量測一段長寬比 1.94:1 的片段,到底有多少「外側」可言。
量測結果帶回了黑邊的尺寸,還有一個沒人特別在找的數字:在 1920×1080 的螢幕上,片段是以自己儲存時的尺寸 1324×682 繪製的,留下螢幕 56.5% 是空白。
對照片段在內文中原本就有的 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;}這刻意跟同一個 repo 套用在點陣圖片上的規則相反:放大對話框拒絕把截圖畫得比原始寬度更大,因為放大只是內插而已。差別在於讀者當下在做什麼。圖表對話框是給人檢視細節用的,還想看更清楚的讀者可以再用瀏覽器縮放。片段則是在內文以一半尺寸播放中被觀看,沒有其他方式能看到更大的畫面,所以畫面上的視覺大小,勝過每個像素的銳利度。所有出過貨的影片播放器,最後都走到了同一個結論。
這裡也有一個看起來很誘人、實際上是錯的修法。在滿版的區塊上使用 object-fit: contain 看起來等價,卻會悄悄拿掉我原本要求的功能:元素本身會蓋滿整個螢幕,黑邊會變成 <video> 的一部分,點擊畫面外側就再也沒有可以落地的地方。
兩套回報錯誤結果的測試
最先寫的兩套驗證機制都錯了,而且方向相反。
同一個工作階段裡出現了一次假的通過和一次假的失敗。前者檢查的性質比真正重要的那個還弱;後者量測的物件,其實早就繼承了現成的答案。
假的通過,來自檢查錯了性質。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;而實測守衛,才是把三個看似合理的修法變成三個真正管用的修法的關鍵。




