昨天我們用 Playwright 錄製了影片與 GIF,但錄出來的影片是無聲的,觀眾看得到游標在動、有紅框和標號,卻不知道每一步在做什麼、為什麼要這樣做。
今天來加上字幕跟旁白,讓觀眾更容易知道我們想傳達的資訊。
範例專案 auto-manual-gen 的 chore/day25 分支,讓 video 指令多產出字幕與旁白:
npm run video # mp4 內含可開關的字幕軌,另外輸出 .srt / .vtt
npm run video -- --burn --gif # 字幕直接燒進畫面
npm run video -- --narrate # 加上 TTS 旁白
npm run video -- --captions-only # 不重錄,只重產字幕
想要上字幕的話,需要兩個東西:文本與時間軸。
文本的部分好解決,基本上直接把原本要寫在使用手冊中的那些文字拿來用就可以了。這些內容已經過 Day 19 的驗收,名稱也對照過 App 的文案,另外再寫一份,遲早會跟正文對不上。
麻煩的是字幕的時間軸。
最直覺的做法是跟著 manifest 的 step 走:執行到哪個 step,就換成哪一句字幕。但畫面的節奏是由動作決定的,跟觀眾讀字幕的速度沒有關係。像「等待清單載入完成」對應的 waitFor,清單早就載入好了,一下就過去,字幕也就一閃而過;「點擊『建立』」之後馬上跳到結果畫面,字還沒讀完就換掉了。
所以字幕不只是被動地跟著 step 出現,還要反過來決定節奏。整個做法分成三步:
正文的步驟跟 manifest 的 step,數量對不起來。正文的「1. 等待清單載入完成」在 manifest 裡是兩個 waitFor;點完按鈕之後等對話框出現的 waitFor,正文裡根本沒寫。
好在兩邊有一個共同的東西:截圖。
正文裡的 {{screenshot:camera-add-01}} 跟 manifest 裡 name: camera-add-01 的 step,名字一樣、順序也一樣。所以先用截圖把兩邊切成一段一段的,再在每一段裡配對:
正文 manifest
1. 等待清單載入完成。 waitFor camera-list-skeleton
2. 點擊「新增攝影機」。 waitFor camera-list
click camera-add
waitFor camera-dialog
{{screenshot:camera-add-01}} <------> screenshot camera-add-01
3. 在「顯示名稱」輸入「大門西側」。 fill camera-dialog-name
4. 在「RTSP 位址」輸入…… fill camera-dialog-source
{{screenshot:camera-add-02}} <------> screenshot camera-add-02
段落裡只看「會動」的 step (click、fill 等),waitFor 是 runner 自己的事,讀者看不到。數量一樣就一對一;正文比較多,就從後面對齊,前面多出來的 (通常是「等待清單載入」這種) 掛在段落開頭;正文比較少,就從前面對齊並提醒一下,這多半是正文漏寫了一步。
截圖停留的時候,如果有標註,就顯示 legend 清單;最後一張圖如果沒有標註,通常就是結果畫面,這時候換成「完成後」的第一段。
每一句字幕都估一個最短的閱讀時間,下一句要出來之前,先等上一句讀完:
// 中文約每秒 9 字、英文約每秒 17 個字元,最少 1.2 秒
const readingMs = (text: string, locale: string) =>
Math.max(1200, Math.round((text.replace(/\s/g, '').length / READING_CPS[locale]) * 1000))
// 到達一個有字幕的錨點:上一句還沒讀完,就先等
const wait = speaking.until - Date.now()
if (wait > 0) await page.waitForTimeout(wait)
speaking = { until: Date.now() + need + PACE.breath }
每秒字數參考的是常見的字幕規範。這樣每一句至少都能停留 1.6 秒以上。
不過畫面停下來等字幕,App 的時間可不會停。Day 10 的經驗分享提到,toast 只活 3 秒,但最後一句字幕「畫面出現『已新增攝影機「大門西側」』的通知,表示攝影機已經建立。」要讀 3.6 秒,加上換氣要停留約 4 秒,字幕還在講通知,畫面上的通知就已經不見了。
回頭看 Day 10:當初為了讓骨架屏的 setTimeout 正常觸發,選擇了 setFixedTime,只固定畫面上的時間,計時器照常跑。既然 Playwright 的假時鐘已經裝好了,影片就可以在停留的時候,把頁面的計時器暫停:
case 'screenshot': {
// 停留期間暫停頁面的計時器,toast 的 3 秒也跟著停
await page.clock.pauseAt(await page.evaluate(() => Date.now()))
// ... 畫標註、等字幕讀完 ...
await page.clock.resume()
}
截圖是「在 toast 消失之前按快門」,影片則是「讓 toast 沒辦法消失」。
錄影時,runner 會記下每個 step 開始的時間,以及每個截圖位置開始停留的時間,存成 {id}.timeline.json:
{
"steps": "c10995f3",
"marks": {
"0:start": 1047,
"2:start": 3371,
"4:hold": 5555,
"5:start": 8857,
...
}
}
字幕的時間就是「錨點 × 時間軸」:每一句從自己的錨點開始,到下一句開始為止。最後輸出成 .srt (影音平台、播放器都認得) 與 .vtt (網頁的 <video><track> 直接吃這個):
1
00:00:01,047 --> 00:00:03,371
等待左側的「攝影機清單」載入完成。
2
00:00:03,371 --> 00:00:05,555
點擊清單右上角的「新增攝影機」。
3
00:00:05,555 --> 00:00:08,857
① 顯示名稱 ② 安裝位置 ③ RTSP 位址 ④ 啟用推論
mp4 可以帶一條獨立的字幕軌,觀眾可以自己開關,多語言時也能在同一個檔案裡放好幾條。ffmpeg 轉檔時把 .srt 一起放進去就好:
ffmpeg -i camera-add.webm -i camera-add.srt -map 0:v -map 1:s \
-c:v libx264 -c:s mov_text -metadata:s:s:0 language=chi ... camera-add.mp4
ffmpeg 寫 mp4 時,文字字幕只能用 mov_text 這種格式,language 則是播放器選字幕時看的語言標籤。
但不是每個地方都認得字幕軌。有些內部系統的播放器會直接忽略它,GIF 更是根本沒有字幕軌可以放。這時候就只能用 --burn 把字幕燒進畫面:

畫面上的標號 1~4,跟下方字幕的 ①~④ 是同一份 legend。
範例專案用的是 Windows 內建的 System.Speech (中文是 Hanhan、英文是 Zira),不用額外安裝或申請金鑰:
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synth.SelectVoice($voice.VoiceInfo.Name)
$synth.SetOutputToWaveFile($job.out)
$synth.Speak($job.text)
錄影之前,先把每句字幕念成一個 wav;錄完之後,ffmpeg 照著時間軸把它們放到對應的位置,混成一條音軌。
內建語音的品質比較普通,正式交付的話,可以換成雲端的 TTS 服務。範例專案把 TTS 包在
tts.ts裡,介面只有「文字進、wav 出、回報長度」,要換只需要換這一支。
字幕是給眼睛看的,旁白是給耳朵聽的,同一句話要先處理過才適合念:
rtsp://192.0.2.10/live 會被一個字元一個字元念出來。我實際測了一下,「在 RTSP 位址輸入 rtsp://192.0.2.10/live」要 6.8 秒,換成「在 RTSP 位址輸入畫面上的位址」只要 4.2 秒。所以旁白裡的網址一律這樣帶過,字幕上仍然是完整的網址。, ,英文句子裡混進全形逗號會念得很怪。TTS 念得比人讀得慢。中文 8 句念完總共 35.6 秒,光是 legend 那一句就要將近 10 秒。
所以有旁白時,前面那套「等上一句讀完」直接改成「等上一句念完」,機制完全一樣,只是每一句需要的時間從估計的閱讀時間,換成 wav 實際的長度。畫面等旁白,而不是旁白追畫面:
$ npm run video -- --narrate
● [zh-Hant] camera-add 新增攝影機
TTS 8 句,共 35.6s
錄完 42.5s -> output/video/zh-Hant/camera-add.webm
字幕 8 句 -> output/video/zh-Hant/camera-add.srt、.vtt
ffmpeg -> output/video/zh-Hant/camera-add.mp4(934 KB,旁白、字幕軌)
同一章,沒有旁白 25.8 秒,有旁白變成 42.5 秒。
最後,來看看實際影片效果吧!(記得開字幕)
最後,來聊一下教學影片的維護。
比起先前的截圖、正文,影片明顯複雜得多,很容易因為小改動而要整個重錄。雖然已經可以自動化了,比起之前還要自己螢幕錄影、剪接影片、上字幕、配音等,已經省下許多時間,但每次重錄都要重新看過一遍,還是有點麻煩。
把幾種常見的改動放在一起看:
| 改動 | 截圖手冊 | 影片 |
|---|---|---|
| 正文改字 | 重新 build | 重產字幕 (有旁白就要重錄) |
| 某一步的畫面變了 | 重拍那一章,其他章不受影響 | 整支重錄,字幕時間軸全部重算 |
| 新增一種語言 | 換 locale 重跑 |
換 locale 重錄,再加一份字幕 (與旁白) |
| 審核、稽核 | 可搜尋、可 diff、可以印出來 | 都不行 |
截圖改一張,影響範圍就是那一張;影片中間改一步,整支都要重錄。而且影片不能搜尋、不能 diff,也不能印出來給稽核的人看。
我覺得,影片雖然教學效果最好,但維護成本也最高,整個流程看下來也比較花時間。因此,通常只有比較關鍵或是複雜的流程才會納入教學影片,剩下細節就請產品使用者 (e.g. 客戶) 自行查看使用手冊就好。這也是 Day 24 讓 manifest 必須明確標上 video: true 才會錄的原因。
今天幫影片加上了字幕與旁白,也整理了影片的維護成本:
.srt / .vtt,mp4 內含字幕軌,也可以燒進畫面。steps 變了就只能重錄。教學影片的部分就到這裡告一段落啦~