跳到內容

Technology

開源|HyperFrames:把 HTML 動畫一格一格算成影片的渲染引擎

HeyGen 開源的 HyperFrames 讓人或 AI agent 用 HTML 寫影片。整理它的引擎怎麼用虛擬時間讓同一份 HTML 每次都算出相同的畫面、怎麼重新渲染子格來做動態模糊,以及怎麼用 CDP 攔截請求把影片幀送進瀏覽器。

AI
Published: at 10:00 AM
編輯本頁下載圖卡

專案 GitHub:heygen-com/hyperframes

重點

專案由 HeyGen 開源,以 2026 年 10 月 13 日從 GitHub API 查到的資料來看,它在 2026 年 3 月 10 日建立,有約 61,850 顆星,授權是 Apache-2.0,最新版本是 10 月 12 日發布的 v0.8.150。README 主打「給 AI agent 用」,附了二十多個教 agent 做影片的 skill。這篇不談那些 skill,只看底下那顆渲染引擎怎麼把網頁變成影片。

內容根據 repo 的 README、packages/core/src/runtime/README.md,以及 packages/engine/src/services/ 裡的 frameCapture.ts、motionBlur.ts、videoFrameTransport.ts。

先認識幾個名詞

要解決的問題:網頁的時間不聽話

把網頁錄成影片,最直覺的做法是打開頁面、錄螢幕。問題在於網頁動畫照真實時鐘跑:電腦一忙,某一格就晚到或掉了;同一個頁面錄兩次,畫面也不會逐格相同。對要放進 CI 做回歸測試、或讓 agent 反覆修改再重算的流程來說,這樣不夠用。

HyperFrames 的想法是把頁面當成一個函式:給它時間 t,它就畫出那一刻的畫面。合成檔(composition)是一個 HTML 檔,元素用 data-start、data-duration 標出在時間軸上的位置,動畫用暫停狀態的 GSAP 時間軸寫,再登記到 window.__timelines:

<script>
  const tl = gsap.timeline({ paused: true });
  tl.from("#title", { opacity: 0, y: 40, duration: 0.8 }, 1);
  window.__timelines = window.__timelines || {};
  window.__timelines.launch = tl;
</script>

執行期(runtime)在頁面上提供 window.__hf = { duration, seek }。引擎要畫第 n 格時,先呼叫 seek 把所有時間軸定位到 n / fps 秒,等頁面回報就緒,再截圖。runtime 的 README 把 renderSeek 定為正式的定位路徑,並把「時間量化到 30fps」和「就緒閘門」(window.__renderReady)列為正確性的要求,不是可有可無的最佳化。GSAP 以外,CSS 動畫、Lottie、Three.js、WAAPI 等也透過 frame adapter 接進同一個 seek 介面。

這個契約也排除了會讓結果不固定的東西。frameCapture.ts 偵測到頁面上有即時地圖(Leaflet、Mapbox 等)時會發出警告,理由寫得很直接:地圖圖磚在渲染時才從網路下載,違反決定性渲染的契約,截圖時圖磚還沒到就會留下空白地圖,而且不會報錯。

一格畫面怎麼產生

flowchart LR
  html["合成檔 index.html"] --> server["本機檔案伺服器"]
  server --> chrome["headless Chrome"]
  chrome -->|"__hf.seek(t)"| ready["等待就緒"]
  video["素材影片"] -->|"FFmpeg 抽幀"| inject["CDP 注入影片幀"]
  inject --> ready
  ready -->|"BeginFrame 或截圖"| png["PNG"]
  png --> enc["FFmpeg 編碼"]
  enc --> mp4["MP4"]

frameCapture.ts 開頭的註解說明了整條路:用 Puppeteer 開啟檔案伺服器上的頁面,等頁面提供 window.__hf,再透過 Chrome 的 BeginFrame API 決定性地截圖,不支援時退回 Page.captureScreenshot。

BeginFrame:讓引擎掌握時鐘

一般情況下,Chrome 依螢幕更新率自己決定何時畫下一格。BeginFrame 是 headless 模式的實驗性 CDP 指令(HeadlessExperimental.beginFrame),開啟後 Chrome 不再自己排程,每一格都要外部送一次 BeginFrame,並附上這一格的時間戳 frameTimeTicks 和間隔 interval。頁面裡的 requestAnimationFrame 看到的時間,就是引擎給的時間。

程式裡有幾個實作細節:

影格間隔直接用分數算,30000/1001(NTSC 的 29.97fps)得到約 33.366 毫秒。

動態模糊:在快門時間裡多拍幾張

真實攝影機的快門開著一小段時間,移動中的物體因此留下拖影。動畫軟體模擬這件事的常見做法,是把元素複製幾份、錯開位置疊起來,這只對平移有效,旋轉和縮放就不對了。HyperFrames 自己的元件庫原本就有這種疊影元件,motionBlur.ts 的註解特別拿它對比。

引擎內建的做法比較暴力:每個輸出格,在快門時間內挑 K 個子時間點,各 seek 一次、截一張,再把 K 張平均。頁面在那段時間裡做了什麼(轉、放大、淡出、濾鏡、子元素自己的動畫)都會被平均進去。

快門參數沿用 After Effects 的模型,以「一格的角度」計:

讓每台機器算出同一個時間點

子時間點如果用浮點數相加算出,不同機器可能差一點點,頁面定位的結果就不同。程式把每一格切成 4096 個整數 tick,樣本時間先換算成整數 tick 再除回秒數,註解說捨入誤差小於八千分之一格,而且可以精確表示。

依動作大小決定樣本數

樣本數可以手動指定(1 到 64),沒指定時用自適應:

  1. 先在快門的兩端各截一張「探測圖」,這兩張不進入平均;
  2. 算兩張圖每個位元組的平均絕對差;
  3. 差值 ≤ 3 用 16 張,≤ 6 用 32 張,再大用 64 張。

註解坦白寫出 32 張那一級是內插出來的,沒有單獨量測。只動透明度、顏色這類非空間屬性的影格會被事先標出,不必探測。

串流累加省記憶體

64 張 1080p 畫面全部解碼放著,要 (64 + 1) × 8.3 MB,約 540 MB,而且每一格都要重來一次。MotionBlurAccumulator 改成每來一張就加進一個 Float32 陣列,峰值只有一個累加器加一張解碼中的圖。顏色以「乘上 alpha」的形式累加,最後再除回來,所以某一張在某個像素是透明的,就不會把顏色帶進去。平均可以在 sRGB 空間做(預設,和 After Effects 的 8-bit 工作空間一致),也可以選線性光,物理上比較正確,但高對比邊緣會和前兩者不同。

靜態格的例外

引擎有一個預設開啟的最佳化:和前一格逐位元組相同的影格,直接沿用上一張截圖。開了動態模糊後,這個判斷不夠用,因為快門時間會跨到前後相鄰的格子,一張靜止的畫面旁邊若有會動的格子,糊出來的結果就不同。motionBlurWindowIsStatic 會把快門時間可能碰到的每一格都檢查過,前一格的快門範圍也算進去,全部都靜止才允許沿用。

影片幀:假裝成網路請求送進頁面

合成檔裡可以放 <video>。問題是 headless Chrome 裡的影片元素很難精準定位到某一幀,邊播邊截圖也會受解碼時機影響。HyperFrames 的做法是在 Node 端用 FFmpeg 先把需要的幀抽成圖片,渲染每一格時再把對應的圖片換進頁面。

最簡單的換法是 data URI,把圖片的 base64 直接塞進 src。videoFrameTransport.ts 的註解說這樣的網址會留在 Chrome 的資源快取裡,比 Node 端自己的快取活得更久,記憶體就一路累積。它改用 CDP 的 Fetch 網域:

  1. 為每個頁面產生一個隨機前綴:<頁面 origin>/__hyperframes_video_frames__/<uuid>/,頁面不是 http(s) 時用 https://hyperframes-render.invalid;
  2. 用 Fetch.enable 只攔截這個前綴的請求;
  3. 每張幀拿到一個短網址(前綴加流水號),對應的位元組先放在 Node 端的 Map;
  4. 頁面載入這個網址時,Chrome 暫停請求、觸發 Fetch.requestPaused,引擎用 Fetch.fulfillRequest 回傳圖片,標頭帶 Cache-Control: no-store;
  5. 這一批注入完成後,從 Map 刪掉這些網址。

回應失敗時,程式一定會再送 Fetch.failRequest,註解寫明理由:協定層的回應失敗必須讓頁面裡的 img.decode() 結束,頁面關閉時也一樣,不然整個渲染會卡住。這個技巧不限於影片,任何需要從 Node 端大量餵圖給瀏覽器的自動化流程都可以參考。v0.8.150 的發布說明也提到,被替換掉的影片幀不再累積快取的圖片資源。

跟常見做法比

README 自己拿 Remotion 比較。兩者都用 headless Chrome 加 FFmpeg,差別主要在寫法:Remotion 要寫 React 元件並經過打包,HyperFrames 的合成檔是一般 HTML,不需要建置步驟,現成的動畫函式庫透過 adapter 接入。授權上,HyperFrames 是 Apache-2.0,Remotion 是自訂的 source-available 授權。

從工程角度看,本文提到的三件事有同一個方向:把「時間」和「資源載入」這兩個在瀏覽器裡最不可控的因素,搬到引擎手上。時鐘由 BeginFrame 給,動作的積分由重新渲染完成,影片畫面由 Node 端供應。

限制與成熟度

參考資料

註釋

編輯本頁

Podcast

關於語音摘要

此語音摘要由 Google NotebookLM 提供,不完全代表作者本人對文章的理解。