不知道大家有沒有想過,既然都已經可以自動化操作產品並截圖製作成使用手冊了,能不能順便(?)錄影,之後可以做成教學影片或是幫助說明的 GIF 檔呢?

答案是:當然可以!
只不過,比起使用手冊,這又再更複雜一點了。因此,接下來會跟大家說明,該怎麼利用前面建立的使用手冊產線,來製作出教學影片~
雖然我說可以錄影,但問題是:怎麼錄?總不會還需要螢幕錄影吧?
當然沒這麼麻煩,Playwright 可以幫我們解決這個問題。(當初選擇用 Playwright 的理由再 +1 XD)
Playwright 傳統的錄影方式是在建立 context 時指定 recordVideo,從視窗一打開就開始錄。但這條產線在開機時會先注入狀態、reload、凍結時間 (Day 09),用 recordVideo 的話,影片開頭會先錄到一段白畫面跟 reload 的閃爍,還得事後再剪掉。
1.59 版之後的 Playwright (範例專案用的是 1.63) 多了 page.screencast,可以在任何時間點開始、停止錄影:
const { driver, page } = await boot(mode, first, locale) // 注入狀態、reload 都在這裡做完
await page.screencast.start({ path: webm, size: { width, height } })
// ... 跑 steps ...
await page.screencast.stop()
開機的流程跟截圖共用同一份 boot(),做完之後才開始錄,影片的第一格就是乾淨的起始畫面。而且它在 Electron 上也能用,不用另外處理。
截圖的 runner 只要把事情做完就好,速度越快越好。但影片是給人看的,同樣的操作直接錄下來,觀眾只會看到畫面一閃就結束了。
所以 video/record.ts 跟 run.ts 讀的是同一份 manifest,差別只在每個 step 怎麼執行:
| step | 截圖 (run.ts) |
影片 (record.ts) |
|---|---|---|
click |
直接點 | 游標先滑過去,點下去時有漣漪 |
fill |
整串文字一次填入 | 點進欄位,一個字一個字打 |
screenshot |
畫標註、按快門 | 畫同樣的標註、把 clip 以外調暗,停留 2.5 秒 |
| 每個動作之後 | 不停 | 停 0.7 秒 |
這些節奏集中寫在一個 PACE 物件裡:
const PACE = {
leadIn: 1000, // 開始操作之前先停一下,讓觀眾看清楚起始畫面
afterAction: 700, // 每個會改變畫面的動作之後
typeDelay: 110, // fill 每個字的間隔
hold: 2500, // screenshot 的位置:標註停留的時間
tail: 2000, // 最後一步之後
}
這些數字沒有標準答案,是看了幾次成品之後調出來的。
Playwright 也有
slowMo可以把所有操作放慢,但它是一視同仁地放慢每一個 API 呼叫,連waitFor、evaluate都會被拖慢。這裡想要的是「觀眾需要看清楚的地方才停」,所以改成在特定位置明確地停。
fill 改成逐字輸入也是一樣的道理。fill() 是一瞬間整串文字出現,觀眾會以為影片剪接時漏了一段。
直接錄下來的話,會有一個很明顯的問題:畫面上沒有游標。
Playwright 是直接對元素派發事件,不會移動真正的滑鼠,錄出來的影片只看得到按鈕自己亮了一下,觀眾根本不知道「剛剛點了哪裡」。
Playwright 的 screencast 其實有內建 showActions(),可以在畫面上顯示游標與操作說明。但它顯示的說明長這樣:Type "大門西側" locator('[data-testid="camera-dialog-name"]'),這是給開發者除錯用的,不適合給使用者看。
所以這裡再用一次 Day 11 的 DOM 疊層:在頁面上放一個 position: fixed 的游標圖示,操作前先把它滑到目標元素的中央,點擊時再放一圈漣漪:
async function pointAt(page: Page, testid: string) {
const locator = await locate(page, testid)
const box = await locator.boundingBox()
await moveCursor(page, box.x + box.width / 2, box.y + box.height / 2)
return locator
}
// click
const target = await pointAt(page, step.testid)
await clickRipple(page)
await target.click()
游標滑動的時間會依距離調整 (0.35 ~ 1 秒):距離近的一下就到,距離遠的也不會拖太久。
截圖停下來按快門的地方,影片就停下來講解。這時候畫的,是跟截圖完全同一套的框線與標號:

這要歸功於 Day 11、Day 12 的決定:標註是畫在頁面上的 DOM,而不是截圖之後再用影像處理畫上去。所以範例專案只是把 annotate() 從 run.ts 搬到 runner/overlay/annotate.ts,截圖跟錄影就能共用,一行都不用重寫。
比較不一樣的是 clip。截圖可以把對話框以外的部分直接裁掉,影片沒辦法裁 (整支影片的尺寸是固定的),所以改成把 clip 以外的區域調暗,效果一樣是「看這裡」。做法也是一個疊層:一個剛好蓋住 clip 範圍的透明方塊,用一個超大的 box-shadow 把周圍塗暗。
page.screencast 錄出來的是 webm (VP8)。它是邊錄邊壓的,檔案偏大,交付之前建議根據用途重新轉成 mp4 或 GIF。
我個人是比較喜歡使用強大的 ffmpeg 來轉檔:
# mp4 (H.264):相容性最好
ffmpeg -i camera-add.webm -c:v libx264 -pix_fmt yuv420p -crf 28 -preset slow \
-movflags +faststart camera-add.mp4
幾個參數說明一下:
-pix_fmt yuv420p:不指定的話會沿用來源的像素格式,有些播放器 (包括 Windows 內建的) 會播不出來。-crf 28:畫面大部分時間是靜止的 UI,28 跟預設的 23 看起來幾乎沒差,檔案小很多。-movflags +faststart:把索引資訊搬到檔案開頭,瀏覽器不用下載完整個檔案就能開始播。ffmpeg 的參數超級多,我根本記不起來,要查文件也很麻煩,強烈推薦請 AI Agent 幫忙寫指令
GIF 則是另一回事。GIF 最多只有 256 色,直接轉的話,ffmpeg 會用一組通用的調色盤,對話框後面那片調暗的灰色會變成一顆一顆的雜點,格線也不見了。所以要先用 palettegen 從整支影片挑出最適合的 256 色,再用 paletteuse 套回去:
ffmpeg -i camera-add.webm -vf "fps=10,scale=960:-1:flags=lanczos,split[a][b];\
[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
camera-add.gif
順便把 fps 降到 10、寬度縮到 960。UI 操作不需要 25 fps,10 fps 就很順了。

不過要注意,palettegen 換到的是畫質,不是檔案大小。同一段影片、同樣 10 fps、960 寬,直接轉是 1.0 MB,用 palettegen 反而是 2.9 MB (把 dither 關掉也還有 2.9 MB)。要乾淨的畫面還是要小的檔案,只能二選一。
這串指令一樣可以請 AI Agent 協助XD
實際執行的輸出:
$ npm run video -- --gif
錄影:camera-add × 2 種語言(zh-Hant / en) [electron]
● [zh-Hant] camera-add 新增攝影機
錄完 21.7s -> output/video/zh-Hant/camera-add.webm
ffmpeg -> output/video/zh-Hant/camera-add.mp4(376 KB)
ffmpeg -> output/video/zh-Hant/camera-add.gif(2.9 MB)
● [en] camera-add Adding a Camera
錄完 23.7s -> output/video/en/camera-add.webm
ffmpeg -> output/video/en/camera-add.mp4(467 KB)
ffmpeg -> output/video/en/camera-add.gif(3.0 MB)

多語言也是順便就有了:video 跟 manual 一樣,會逐語言把 locale 注入之後重跑一遍 (Day 22)。英文版的影片裡,介面、輸入的示範值都是英文。
跟前幾天一樣,產出的影片與 GIF 我放在 範例專案的 Release,大家有興趣的話可以下載來看看。
同一份 manifest,現在可以產出三種東西:
| 形式 | 優點 | 代價 |
|---|---|---|
| 截圖 + 文字 | 可搜尋、可列印、局部重拍 | 看不到動作過程 |
| GIF | 可以嵌進網頁手冊自動播放 | 沒有聲音、256 色、檔案大 |
| mp4 | 表現力最好,之後可以加字幕與旁白 | 要點開才能看、改版要整段重錄 |
以同一段約 28 秒的錄影來說,mp4 大約 425 KB,GIF 卻有 2.9 MB,大約是 7 倍。GIF 適合放在網頁手冊裡、跟著步驟文字自動播放的短片段;要完整說明一個流程,mp4 會比較合適。
bootstrap 的 disableAnimations 會注入 .no-motion * { transition: none !important },把 App 的所有轉場都關掉,截圖才會穩定。
但假游標也是 body 底下的元素。如果用 CSS transition 做滑動,會跟 App 的轉場一起被關掉,游標會直接瞬移到目標,完全沒有滑動的過程。
所以游標的動畫改用 Web Animations API (element.animate()),它不受 CSS 的 transition 屬性影響:
await cursor.animate(
[{ transform: from }, { transform: `translate(${x}px, ${y}px)` }],
{ duration, easing: 'ease-in-out' },
).finished
而且 .finished 是一個 Promise,可以等游標真的到位之後才點擊,不會出現「點擊比游標先到」的畫面。
今天把同一份 manifest 錄成了教學影片:
steps。page.screencast 在開機完成後才開始錄,影片開頭不會有 reload 的閃爍。不過,現在的影片還是無聲的,觀眾看得到動作,但不知道每一步在做什麼、為什麼要這樣做。明天來處理字幕跟旁白!