iT邦幫忙

2026 iThome 鐵人賽

DAY 3
1
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 3

Day 3:測試崩潰永遠在最後一秒:PyAV 串流編碼與 CI 錄影的三道防線

  • 分享至 

  • xImage
  •  

昨天確認了執行緒站在對的桌面上。今天的目標很明確:

一支涵蓋整場測試不中斷時間感正確的影片,而且在 x64 和 ARM64 上都要能錄。

聽起來像是「找個套件呼叫一下」。但這三個要求各自推導出一個架構決定,而寫完之後,還需要三道防線來解決「影片能播,但內容在騙你」的隱形問題。

Files #45816 reproducing video
實際成果先放這裡,這是 CI 上真的錄出來的東西:

架構 測試長度 檔案大小
x64 完整測試 108 秒 2.5 MB
ARM64 完整測試 125 秒 3.3 MB

三個架構決定:讓錄影「能夠跑完」

1. 跨架構:編碼器必須跟著 wheel 一起裝

第一個直覺是開一個 ffmpeg 子處理程序,把原始畫面從 stdin 餵進去。ffmpeg 什麼格式都吃,程式碼也短。

代價是使用者的機器上要先裝好 ffmpeg。在 CI 上這代表每個 workflow 都要多一個安裝步驟,而那個步驟在 Windows ARM64 上並不好寫——Chocolatey、winget、手動下載解壓縮,每一種在 ARM64 上的支援狀況都不一樣。

我要的行為很單純:pip install wintegrate,錄影就會動,不管跑在什麼 CPU 上。

這直接決定了技術選型:編碼器得是編進 extension module 的原生函式庫,而不是外部執行檔。PyAV 是少數在 PyPI 上提供 win_arm64 wheel 的方案(cp311-abi3-win_arm64)。因為用的是 Python stable ABI(abi3),3.11 以上通用,不會遇到新版 Python 一出就要枯等 wheel 的問題。

選相依套件時,「哪個好用」通常不是真正的問題。真正的問題是它在你的所有目標平台上到底裝不裝得起來。

2. 記憶體:把幀留著,你錄不完兩分鐘

第二個直覺是先把每幀存在記憶體、測試結束再一口氣寫檔:

frames = []
while recording:
    frames.append(capture())
write_video(frames)          # 千萬不要

算一下(桌面截圖預設為 32-bit BGRA,每像素 4 bytes):

1024 × 768 × 4 bytes ≈ 3.14 MB / 幀
30 fps × 120 秒      = 3600 幀
3600 × 3.14 MB       ≈ 11.3 GB

一場兩分鐘的測試會吃掉超過 10 GB,而測試本身還要跑。這不是「效率差」,是這個做法根本錄不完一場測試

而且它的失敗方式很隱晦:不是噴出清楚的 OOM,而是 runner 開始 swap、時序錯亂,最後某個不相干的測試隨機逾時。你會花大把時間去追那個根本沒壞的測試。

所以擷取迴圈必須是「抓一幀、編一幀、拋棄一幀」:

def _record_loop(self):
    attach_to_input_desktop()             # Day 2: 先確認自己在對的桌面上
    while not self.stop_event.is_set():
        try:
            img = capture_screen_image()
            if self._container is not None:
                self._encode_pyav_frame(img)   # 編完就丟,不留參照
                self._frame_count += 1
        except Exception as exc:
            logger.debug(f"Screen capture frame skipped ({type(exc).__name__}): {exc}")

11 GB 的原始像素最後只佔 2.5 MB。編碼器在這裡的作用不只是壓縮檔案,更是讓錄完整場測試在記憶體預算內根本可行

3. 設計哲學:錄影失效絕不能弄倒測試

錄影是診斷工具,不是被測物本身。

因此 start() 回傳 bool 而不是拋出例外,連 stop() 的資源釋放都包在 try 裡。單一幀抓取失敗只記 debug log 就繼續往下走——桌面切換瞬間偶爾會掉幀,為了一張畫面中斷整場錄影極不划算。

診斷工具失效時應該安靜退場,而不是把自己的問題偽裝成被測物的問題。

一個因為錄影模組掛掉而紅燈的測試,只會浪費整組工程師的時間去查毫無問題的產品程式碼。


三道防線:讓錄出來的資訊真實有效

上面的架構解決了「錄得完」。但如果不做防禦,產出來的影片往往「能順利播放,但內容在騙你」——這比錄影失敗更危險,因為你不會懷疑它。

防線一:PTS 綁定真實時鐘,避免影片「被快轉」

直覺寫法往往是這樣:

for i in range(n):
    frame = capture()
    frame.pts = i          # 第 i 幀就給第 i 個時間戳
    mux(encode(frame))
    time.sleep(1 / fps)

在本地開發機上很順,在負載沉重的 CI runner 上會徹底騙你。因為 capture() 要花時間——BitBlt 加上記憶體複製,在繁忙的 VM 裡可能要 50ms 到 100ms。當擷取跟不上目標幀率時,你實際上每 100ms 才抓出一幀,但你卻告訴編碼器每幀只隔了 33ms。

產出的結果就是一支被「快轉」的假影片:真實世界過了 30 秒,影片播放只要 10 秒。如果你想拿這支影片確認「某個對話框究竟停留了多久」,這個時序會徹底誤導除錯。

永遠問時鐘,不要問計數器:

# stream.codec_context.time_base 設為 Fraction(1, 1000) (毫秒)
frame.pts = int((time.monotonic() - self._t0) * 1000)
frame.time_base = Fraction(1, 1000)

當系統繁忙時,影片播放速度不會變快,只會如實呈現掉幀與卡頓——卡頓本身就是珍貴的診斷資訊,它如實記錄了 runner 當下的資源擠壓。

ffprobe 檢驗實際成果(在多媒體世界中,FFmpeg 一律使用「分子/分母」的分數格式表示幀率,以徹底杜絕浮點數運算累積的時間戳漂移):

r_frame_rate=2000/1
avg_frame_rate=774400/32257   # ≈ 24.0
duration=10.08
nb_frames=242

r_frame_rate=2000/1 不是 bug,但它也不是這支影片的幀率——那個值由 mp4 muxer 的
timescale 決定,幾支長度與幀數完全不同的錄影量出來都是同一個數字。拿它驗收永遠會得到
同一個答案,也就是沒有答案。

真正有意義的驗收指標是 avg_frame_rateduration:242 幀、10.08 秒(平均約 24.0 fps),而且播放長度與真實經過的時間完全吻合。這就是驗收標準。

防線二:寬高對齊偶數,防禦虛擬螢幕的奇數解析度

H.264 的 YUV420p 色度取樣要求畫面寬高必須是偶數。如果把 1919×1079 丟給編碼器,底層會直接拒絕初始化。

實體螢幕幾乎都是偶數,但在無 GUI 雲端 VM、遠端桌面、或虛擬顯示卡上,奇數解析度屢見不鮮。因為我們的原則是「錄影失效不拋例外」,一旦初始化被拒絕,結果就是錄影無聲放棄,測試跑完了才發現根本沒檔案。

防禦方式很直接,抓取螢幕尺寸時直接做位元遮罩:

# libx264 嚴格要求偶數寬高;虛擬環境下的奇數解析度雖然少見但確實存在
w = user32.GetSystemMetrics(0) & ~1
h = user32.GetSystemMetrics(1) & ~1

在編碼時如果原始截圖尺寸與偶數邊界不符,再透過 PyAV 重構尺寸:

if (frame.width, frame.height) != (w, h):
    frame = frame.reformat(w, h, "yuv420p")

一行防禦性代碼,抹掉一個只有在 CI 特殊環境下才會偶發的玄學問題。

防線三:關閉前 Flush 編碼器,保證失敗現場完整落盤

這是三個細節裡最陰險的一個。

現代影像編碼器為了計算 B-frame 與運動預測,內部會保留緩衝區。這代表當你送進最後一幀時,編碼器內部其實還壓著最後幾幀沒吐出來。如果直接關閉容器,那些幀就永遠留在記憶體裡了。

if self._container is not None:
    # 關閉前必須 flush 編碼器,否則最後幾幀(也就是測試失敗的關鍵現場)永遠不會寫入檔案
    try:
        for packet in self._stream.encode(None):
            self._container.mux(packet)
    except Exception as exc:
        logger.debug(f"PyAV encoder flush warning ({type(exc).__name__}): {exc}")
    try:
        self._container.close()
    except Exception as exc:
        logger.debug(f"PyAV container close warning ({type(exc).__name__}): {exc}")

註解點出了最殘酷的現實:測試失敗的瞬間,永遠落在錄影的最後一秒。

一個忘了 flush 的錄影器,錄正常測試一切順利,偏偏在測試爆掉的最關鍵時刻把案發現場切掉——而且影片依然能正常播放,你甚至不會意識到它少了一段。

工具在失敗路徑上的表現,比在成功路徑上重要得多。
一個一出事就少掉關鍵證據的工具,比沒有工具更危險,因為你對它抱有盲目的信任。


小結

把所有細節收進內部後,API 對外的面貌變得很單純:副檔名自動決定編碼器(.mp4 走 H.264,.webm 走 VP9),呼叫端完全不需要理解底層參數:

codec = "libx264" if self.output_path.suffix.lower() == ".mp4" else "libvpx-vp9"
目標 決策核心 為什麼這樣做
跨架構即裝即用 採用 PyAV (cp311-abi3-win_arm64) 省去 CI 上額外安裝、配置 ffmpeg 執行檔的負擔
記憶體不超標 抓一幀、編一幀、丟一幀 避免 10+ GB 原始圖像引發系統 swap 造成測試逾時
錄影不拖累測試 全程 fail-safe、回傳布林值 診斷工具的異常不能被誤判為被測物本身的錯誤
真實時序感 PTS 綁定 time.monotonic() 避免高負載環境下產出快轉、失真的偽造時序
環境解析度容錯 寬高位元遮罩 & ~1 避免 CI 虛擬螢幕產生奇數解析度導致編碼器拒絕工作
保留崩潰現場 關閉前以 stream.encode(None) flush 確保最後一刻的測試失敗現場確實寫入磁碟

明天講截圖與視窗普查——以及一張全黑的截圖為什麼比沒有截圖更危險。


上一篇
Day 2:你的視窗不是你的視窗:Session、Window Station、Input Desktop 的三層蛋糕
下一篇
Day 4:「成功回傳」不等於「拿到有用的東西」
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言