iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
AI 自動化

用 AI Agent 打造你的產品使用手冊產線系列 第 24 篇

[Day 24] 教學影片 1:用 Playwright 錄製影片與 GIF

  • 分享至 

  • xImage
  •  

不知道大家有沒有想過,既然都已經可以自動化操作產品並截圖製作成使用手冊了,能不能順便(?)錄影,之後可以做成教學影片或是幫助說明的 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 把周圍塗暗。

轉檔:mp4 與 GIF

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

不過要注意,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)

新增攝影機的 GIF

多語言也是順便就有了:video 跟 manual 一樣,會逐語言把 locale 注入之後重跑一遍 (Day 22)。英文版的影片裡,介面、輸入的示範值都是英文。

跟前幾天一樣,產出的影片與 GIF 我放在 範例專案的 Release,大家有興趣的話可以下載來看看。

三種形式的取捨

同一份 manifest,現在可以產出三種東西:

形式 優點 代價
截圖 + 文字 可搜尋、可列印、局部重拍 看不到動作過程
GIF 可以嵌進網頁手冊自動播放 沒有聲音、256 色、檔案大
mp4 表現力最好,之後可以加字幕與旁白 要點開才能看、改版要整段重錄

以同一段約 28 秒的錄影來說,mp4 大約 425 KB,GIF 卻有 2.9 MB,大約是 7 倍。GIF 適合放在網頁手冊裡、跟著步驟文字自動播放的短片段;要完整說明一個流程,mp4 會比較合適。

經驗分享

游標的動畫不能用 CSS transition

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 的閃爍。
  • 同一份 step,影片版多了游標、逐字輸入與停頓;標註跟截圖共用同一套疊層。
  • ffmpeg 轉成 mp4 與 GIF,GIF 要先產生調色盤才不會滿是雜點。

不過,現在的影片還是無聲的,觀眾看得到動作,但不知道每一步在做什麼、為什麼要這樣做。明天來處理字幕跟旁白!


上一篇
[Day 23] 多語言 2:翻譯同步
系列文
用 AI Agent 打造你的產品使用手冊產線 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言