昨天確認了執行緒站在對的桌面上。今天的目標很明確:
一支涵蓋整場測試、不中斷、時間感正確的影片,而且在 x64 和 ARM64 上都要能錄。
聽起來像是「找個套件呼叫一下」。但這三個要求各自推導出一個架構決定,而寫完之後,還需要三道防線來解決「影片能播,但內容在騙你」的隱形問題。

實際成果先放這裡,這是 CI 上真的錄出來的東西:
| 架構 | 測試長度 | 檔案大小 |
|---|---|---|
| x64 完整測試 | 108 秒 | 2.5 MB |
| ARM64 完整測試 | 125 秒 | 3.3 MB |
第一個直覺是開一個 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 的問題。
選相依套件時,「哪個好用」通常不是真正的問題。真正的問題是它在你的所有目標平台上到底裝不裝得起來。
第二個直覺是先把每幀存在記憶體、測試結束再一口氣寫檔:
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。編碼器在這裡的作用不只是壓縮檔案,更是讓錄完整場測試在記憶體預算內根本可行。
錄影是診斷工具,不是被測物本身。
因此 start() 回傳 bool 而不是拋出例外,連 stop() 的資源釋放都包在 try 裡。單一幀抓取失敗只記 debug log 就繼續往下走——桌面切換瞬間偶爾會掉幀,為了一張畫面中斷整場錄影極不划算。
診斷工具失效時應該安靜退場,而不是把自己的問題偽裝成被測物的問題。
一個因為錄影模組掛掉而紅燈的測試,只會浪費整組工程師的時間去查毫無問題的產品程式碼。
上面的架構解決了「錄得完」。但如果不做防禦,產出來的影片往往「能順利播放,但內容在騙你」——這比錄影失敗更危險,因為你不會懷疑它。
直覺寫法往往是這樣:
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_rate 與 duration: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 特殊環境下才會偶發的玄學問題。
這是三個細節裡最陰險的一個。
現代影像編碼器為了計算 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 |
確保最後一刻的測試失敗現場確實寫入磁碟 |
明天講截圖與視窗普查——以及一張全黑的截圖為什麼比沒有截圖更危險。