專案 GitHub:heygen-com/hyperframes
重點
- HyperFrames 用 HTML 當影片的原始檔:寫好的網頁交給 headless Chrome 一格一格「跳到某個時間點再截圖」,最後由 FFmpeg 編成 MP4。同一份輸入要得到同一支影片。
- 時間由渲染端決定:在 Linux 上,引擎用 Chrome 的 BeginFrame 指令自己推進頁面時鐘,頁面上的動畫只能被「定位到第幾秒」,不能自己照真實時間跑。
- 動態模糊靠重新渲染:每個輸出格在快門時間內取 16 到 64 個子時間點分別截圖再平均,所以旋轉、縮放、透明度變化都會糊得正確。
- 影片幀偽裝成網路回應:Node 端用 FFmpeg 抽出影片畫面,再透過 CDP 攔截特定網址,把圖片位元組當成 HTTP 回應送進頁面。
- 成熟度:Apache-2.0,2026 年 3 月建立,最新版 v0.8.150,發版很頻繁;README 與技能包的行銷份量重,真正的工程細節集中在
packages/engine。
專案由 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。
先認識幾個名詞
- headless Chrome:沒有視窗的 Chrome,由程式控制,常用在自動化測試與截圖。
- CDP(Chrome DevTools Protocol):開發者工具和 Chrome 溝通用的協定。程式可以透過它下指令,例如截圖、攔截網路請求、控制畫面何時更新。
- Puppeteer:Google 維護的 Node.js 函式庫,把 CDP 包成比較好用的 API。HyperFrames 用的是
puppeteer-core。 - FFmpeg:處理影音的命令列工具,這裡負責把一張張截圖編成影片,以及從素材影片裡抽出單張畫面。
- GSAP:常見的 JavaScript 動畫函式庫。它的時間軸可以暫停,再直接跳到任意時間點。
要解決的問題:網頁的時間不聽話
把網頁錄成影片,最直覺的做法是打開頁面、錄螢幕。問題在於網頁動畫照真實時鐘跑:電腦一忙,某一格就晚到或掉了;同一個頁面錄兩次,畫面也不會逐格相同。對要放進 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 看到的時間,就是引擎給的時間。
程式裡有幾個實作細節:
- 只在特定條件下啟用:要在 Linux、找得到 headless shell 版本的 Chrome,而且沒有要求強制截圖、沒有超取樣、沒有輸出透明背景時,才會以
beginframe模式啟動瀏覽器;其他情況(包括 macOS)走截圖模式。 - 暖機:頁面載入時,引擎以固定間隔(約 33 毫秒)持續送 BeginFrame,讓頁面跑完初始化。
lockWarmupTicks選項會把暖機次數固定為 60 次,讓起始時間戳在快慢不同的機器上都一樣。 - 時間只往前:
deriveBeginFrameTimeTicks會確保第 0 格的時間戳排在暖機時鐘之後,暖機間隔和輸出幀率不同時也不會讓時間倒退。
影格間隔直接用分數算,30000/1001(NTSC 的 29.97fps)得到約 33.366 毫秒。
動態模糊:在快門時間裡多拍幾張
真實攝影機的快門開著一小段時間,移動中的物體因此留下拖影。動畫軟體模擬這件事的常見做法,是把元素複製幾份、錯開位置疊起來,這只對平移有效,旋轉和縮放就不對了。HyperFrames 自己的元件庫原本就有這種疊影元件,motionBlur.ts 的註解特別拿它對比。
引擎內建的做法比較暴力:每個輸出格,在快門時間內挑 K 個子時間點,各 seek 一次、截一張,再把 K 張平均。頁面在那段時間裡做了什麼(轉、放大、淡出、濾鏡、子元素自己的動畫)都會被平均進去。
快門參數沿用 After Effects 的模型,以「一格的角度」計:
- 快門角度預設 180°,代表快門開半格的時間;
- 快門相位預設 −90°,讓這段時間以影格時間點為中心;
- 第 k 個樣本落在
windowStart + (k + 0.5) / N × shutterTime。
讓每台機器算出同一個時間點
子時間點如果用浮點數相加算出,不同機器可能差一點點,頁面定位的結果就不同。程式把每一格切成 4096 個整數 tick,樣本時間先換算成整數 tick 再除回秒數,註解說捨入誤差小於八千分之一格,而且可以精確表示。
依動作大小決定樣本數
樣本數可以手動指定(1 到 64),沒指定時用自適應:
- 先在快門的兩端各截一張「探測圖」,這兩張不進入平均;
- 算兩張圖每個位元組的平均絕對差;
- 差值 ≤ 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 網域:
- 為每個頁面產生一個隨機前綴:
<頁面 origin>/__hyperframes_video_frames__/<uuid>/,頁面不是 http(s) 時用https://hyperframes-render.invalid; - 用
Fetch.enable只攔截這個前綴的請求; - 每張幀拿到一個短網址(前綴加流水號),對應的位元組先放在 Node 端的 Map;
- 頁面載入這個網址時,Chrome 暫停請求、觸發
Fetch.requestPaused,引擎用Fetch.fulfillRequest回傳圖片,標頭帶Cache-Control: no-store; - 這一批注入完成後,從 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 端供應。
限制與成熟度
- 決定性有前提:BeginFrame 路徑只在 Linux 加 headless shell 時使用;macOS 與需要透明背景、超取樣的情況走一般截圖。合成檔也必須守規矩,動畫要能被 seek,渲染途中不該依賴網路。
- 動態模糊很貴:每格 16 到 64 次 seek 與截圖,渲染時間大致跟著倍增。
- 快速擷取仍是實驗功能:
frameCapture.ts裡另有一條用canvas.drawElementImage的快速路徑,只在支援的 Chrome 版本上啟用,遇到模糊濾鏡、3D、混合模式等效果會退回一般截圖,註解裡也記錄了 Chromium 的相關 bug。 - 發版頻繁:版本號已經到 0.8.150,API 細節常變,例如 v0.8.150 就改了
injectVideoFramesBatch的網址行為。引用程式細節時要對照版本。 - 本文沒有實測:上面的數字都取自原始碼註解與文件,沒有自己跑過渲染。