Deck to Video (投影片 + 旁白 → 自播放 MP4)
概述
將 N 張投影片圖片(或一個 PDF)與 N 個對應的旁白音訊片段,合併成一個 MP4 檔案:每張投影片會持續其旁白長度加上一個短暫停頓,然後硬切換到下一張。疊加內容:一個會逐漸填滿的進度條 + 剩餘秒數倒數計時器(右下角),以及一個「第 N / M 頁」投影片計數器(右上角)。
單一指令(腳本位於此技能的 scripts/ 目錄——透過完整路徑呼叫):
python3 <此技能目錄>/scripts/build_video.py SLIDES AUDIO_DIR out.mp4
# SLIDES = slides.pdf (以 200 dpi 光柵化) 或一個包含逐頁圖片檔案的目錄
# (會以數字感知方式排序:s2 排在 s10 之前;仍建議使用補零名稱)
# AUDIO_DIR = slide01.mp3, slide02.mp3, … — 數量必須與投影片數量相等
# 選項:--size 1920x1080 · --pad 0.7 · --no-bar · --no-counter · --bar-color HEX · --crf 20
# --highlights hl.json · --anim-fps 30 (見下方「元素高亮」說明;
# scripts/make_highlights.py 會建立 hl.json)
成功時,它會為每張投影片印出一行——該行包含的長度 已包含停頓時間——並以 duration matches segment sum 結束(否則會以非零狀態碼結束)。
準備正確的輸入
- 投影片:如果從 pptx 開始,請透過真正的 PowerPoint 進行光柵化,而不是 LibreOffice。 透過實際的 PowerPoint 將 pptx 匯出為 PDF(office-render 技能可在 macOS 上執行此操作),然後將 PDF 交給腳本——LibreOffice 會替換字型並重新排列版面,因此影片將與簡報作者所見的內容不符。200 dpi 可在 1080p 下保持密集投影片文字的清晰度。(已有逐頁圖片?直接使用它們——跳過此步驟。)
- 音訊:每張投疊一個片段,命名為
slideNN.mp3。 如果片段來自 elevenlabs-tts 的generate_tts.py(narrating-pptx 使用此工具),它們已經是這種確切格式——重複使用它,這樣 pptx 和影片聽起來就會一致。 - 如果旁白速度已被調整(例如
ffmpeg -filter:a atempo=1.1),請從 調整後 的檔案來建立影片。
為何使用 PIL 烘焙疊加層,而非由 ffmpeg 繪製(以血淚換來的教訓)
drawbox搭配以t為基礎的寬度表達式,在許多 ffmpeg 版本中從第 0 幀就繪製出一個完整的 BAR——表達式只在初始化時評估一次,而不是逐幀評估。失敗是靜默的:影片可以正常編碼,但該條從來不會移動。drawtext表達式在相同版本中確實會逐幀更新,這使得損壞的進度條在一個正常工作的倒數計時器旁邊很容易被忽略。- Homebrew 的 ffmpeg 瓶子(2026 年之後的版本)是在沒有 libfreetype 的情況下構建的——會發生
No such filter: 'drawtext'錯誤——並且 keg 版本(ffmpeg@7、ffmpeg@8)也是如此。不要花一個安裝週期來發現這個問題;如果出於其他原因需要完整構建,uv tool install static-ffmpeg提供了一個(腳本會自動備援)。
因此腳本使用 PIL(進度條填充、倒數計時、計數器)烘焙幀,並將以 -r 30 編碼的圖片序列提供給 ffmpeg。確定性、字型可控,並且編碼成本低於過濾 30 fps 串流。預設值為每秒一幀,當投影片上沒有任何東西移動時,這是最合適的——對於倒數計時器來說,按秒更新的讀取方式是自然的。承載 --highlights 的投影片則以 30 fps 烘焙,原因請見下一節。
腳本已做出的編碼選擇(不要撤銷它們)
| 選擇 | 原因 |
|---|---|
apad=pad_dur=PAD + -t seg_dur | 精確的尾部填充;天真的 -shortest 會截斷語音後的停頓 |
yuv420p + 偶數尺寸 | 其他任何東西都無法在 QuickTime/WhatsApp/瀏覽器中播放 |
-tune stillimage, CRF 20 | 靜態投疊可以良好壓縮,不會產生文字閃爍 |
concat demuxer + -c copy | 無損連接相同編碼的區段 |
-movflags +faststart | 影片在完成下載前即可開始串流 |
驗證——在幀數證明之前,假設疊加層是壞的
腳本會斷言最終時長 ≈ 各段落的總和。疊加層需要肉眼檢視:
ffmpeg -y -ss T1 -i out.mp4 -frames:v 1 a.jpg # 某張投影片較早位置
ffmpeg -y -ss T2 -i out.mp4 -frames:v 1 b.jpg # 同一張投影片較晚位置
從腳本列印出的每張投影片長度中挑選 T1/T2(將它們累積求和以獲得投影片邊界;記住它們已包含停頓)。讀取兩張圖片。b.jpg 中的進度條必須明顯更滿,且倒數計時更低——並且它應該在每張新投影片處重置。在較早時間戳看到一條看起來完整的進度條 = 靜態進度條錯誤——正是這個檢查最初捕獲了它。你無法在無頭模式下驗證音訊——請直接說明;永遠不要聲稱你聽到了。
與旁白同步高亮元素
如果旁白說「左邊……中間……右邊」,聽起來就像一條音訊描述軌道(參閱 self-presenting-decks)。解決辦法是移除口頭指示並改為讓元素發光——但前提是時間點必須來自音訊。
音訊是主體;動畫跟隨它。 永遠不要為了配合某個時間點而編寫旁白,也不要估計某個短語何時落地。正常生成片段,然後使用 ElevenLabs 強制對齊(POST /v1/forced-alignment,音訊 + 文字 → 每個字元的 start/end;透過 elevenlabs-tts 的 scripts/align_narration.py 執行)來回讀每個東西實際被說出的時間,並從中推導出時間窗口。稍後重新錄製一個片段並重新對齊,即可自我修正。
- 錨定在腳本中的位置,而不是關鍵字。 一個孤立的關鍵字在它出現兩次時就會失效(「標籤」在一張投影片上出現了四次)。標記旁白轉向每個元素的時間點,使用從腳本中複製的一個簡短短語,按照口語順序,並且僅在上一個匹配之後才搜尋每一個——這就是
make_highlights.py所做的;腳本中較早出現的重複單字無法捕獲它。(保持每個元素在合併腳本中的偏移量也可以。) - 在對齊之前,移除所有音訊標籤。
[confident]是被表演出來的,而不是被說出來的;將其發送給對齊器,它會搜尋該單字在音訊中的位置,並將所有後續偏移都拖拽過去。Eleven v4 標籤是自由格式的([Pause, dry amusement]),所以請移除括號內的任何內容,而不僅僅是字母和空格(elevenlabs-tts 的對齊器會這樣做)。根據同一個已移除標籤的字串來計算偏移量。 - 檢查返回的字元計數是否等於文字長度,並觀察
loss(健康的 17 個片段英文簡報為 0.04-0.10,健康的 26 個片段希伯來文簡報為 0.59-0.77;簡報中明顯的離群值是信號,而不是絕對閾值)。不匹配意味著該張投影片上的每個時間窗口都是錯誤的。
使用 scripts/make_highlights.py 從三個輸入來建立 --highlights JSON:標記(每張投疊一個,每個區域包含旁白轉向該區域的短語)、框(每張投疊一個,區域 → [x, y, w, h])和 alignment/ 檔案:
python3 <此技能目錄>/scripts/make_highlights.py markers.json boxes.json narration/alignment/ hl.json \
--size 1920x1080 [--inches 13.333] # --inches: 框以投疊英寸為單位,頁面寬度為此值
# markers.json: {"09": [["chart", "Last quarter"], ["callout", "The real problem"], ["_end", "Now, to"]],
# "12": {"marks": [["a", "First"], ["b", "Second"]], "dim": "black", "colour": "#E07A5F"}}
一個區域的時間窗口從它的短語開始到下一個標記的短語結束(_end 結束一個短語而不開啟另一個;否則最後一個短語將持續到語音結束),並提前 0.12 秒開始。時間窗口小於 1.4 秒的會被跳過並發出警告,因為 0.5 秒的淡入淡出之後就沒什麼可看的了。當一個短語不存在於腳本中、一個區域沒有對應的框、一個框超出畫面邊界或一個對齊檔案與其腳本不匹配時,它會停止,不寫入任何內容。當一個框距離邊緣在 19 像素以內(發光會被裁切)時,它會發出警告。
結果 JSON,鍵值為補零的投影片編號,框的單位為輸出像素:
{"09": [{"card": "chart", "box": [87, 468, 549, 374], "colour": "#B45309",
"dim": "white", "start": 13.6, "end": 22.9}]}
從簡報本身的版面配置程式碼(英寸 → 像素)取得 box,永遠不要透過肉眼觀察渲染結果——一個元件在繪製它的建構者中,其幾何形狀已經是精確的。dim 在淺色投疊上是 white,在深色投疊上是 black;也請在深色投疊上檢查發光顏色(一個深梅子色的發光在近乎黑色的結尾投疊上消失了)。一個區域在一張投疊上可以被高亮多次。該檔案中缺少的投疊會像往常一樣以 1 fps 烘焙。
一旦對齊,字幕就免費附帶了。 同樣的字詞時間點可以用來製作一個 .srt 檔案,無需第二次轉錄:
python3 <此技能目錄>/scripts/make_srt.py narration/alignment/ narration/audio/ out.srt --pad 0.7
--pad 必須與影片建立時使用的 pad 值相符,否則提示在每張投疊後會偏移得更遠。
三種提供方式,由目標平台決定。
| 形式 | 如何操作 | 觀看者將得到什麼 |
|---|---|---|
側載 .srt | 將檔案與 mp4 一起提供 | 只有在真正的播放器中同時開啟兩者時才顯示字幕 |
| 軟軌道 | -c copy -c:s mov_text -disposition:s:0 default — 無損封裝,約 12 KB,只需幾秒 | 取決於播放器;需要有 CC 控制鈕 |
| 燒錄進去 | 透過 libass 重新編碼 | 永遠可見,隨處可見,無法關閉 |
預設使用側載或軟軌道——兩者都是可逆的,並且都不會影響畫面。僅當影片將在沒有字幕控制的地方播放時才燒錄。 Slack 或 Teams 討論串的內嵌播放器就是強迫這種做法的情況:在該處直接點擊播放的觀看者從其他兩種形式中都得不到任何東西。
燒錄字幕
ffmpeg -i deck.srt /tmp/subs.ass # 先轉換;不要傳遞 force_style
# …編輯 /tmp/subs.ass 中的 Style: 行(見下方),然後:
<static-ffmpeg> -i master.mp4 -vf "ass=/tmp/subs.ass" \
-c:v libx264 -crf 20 -preset medium -tune stillimage -pix_fmt yuv420p \
-c:a copy -sn -movflags +faststart out.mp4
四個每樣都會耗費一個週期的事情:
- Homebrew 的 ffmpeg 沒有 libass——與
drawtext/libfreetype 相同的缺口,而它的提示信息具有誤導性。你會得到No option name near '<filename>',一個濾波器圖解析錯誤,而不是No such filter,因此它看起來像是一個引號 bug,導致你重寫那些本來就已經正確的轉義字元。用ffmpeg -filters | grep -E ' (ass|subtitles) '檢查,並使用 static-ffmpeg 版本。 - 設計
.ass檔案樣式;永遠不要將force_style塞入濾波器字串。 它的逗號會被解讀為濾波器分隔符,而用反斜線轉義它們非常脆弱,以至於在成功之前會失敗兩次。 - 根據
Format:標頭來映射 ASS 樣式欄位,永遠不要透過計算位置。 差一個位置會將 Alignment=0(無效)寫入,並使 MarginV 未設定,而 libass 在沒有任何警告的情況下渲染它。 - 從預燒錄的主影片檔案燒錄,並保留那個主影片檔案。 從一個已經燒錄的檔案重新設計樣式會疊加第二個世代並且將舊文字永久地烘焙進去。
放置——下方條帶或疊加。由簡報決定,而非個人喜好。
| 如何操作 | 成本 | |
|---|---|---|
| 投疊下方條帶 | pad=1920:1200:0:0:black 然後 ass=,MarginV 在條帶內 | 投疊上的任何東西都不會被遮蓋;輸出不再是 16:9 |
| 疊加(熟悉的 VLC 外觀) | 僅 ass=;底部居中,1080p 下粗體約 48 像素,厚重的黑色外框,MarginV 約 40 | 經典且自包含,但肯定會遮蓋簡報放在底部條帶中的任何內容 |
「疊加」就是大多數人所說的「字幕」。在文字密集的簡報上,請先提取一幀檢視再決定:它會落在頁尾的註解、圖例和進度條上。
1 fps 不僅是流暢度的限制,也是同步的限制。 預設烘焙是每秒一幀,所以高亮只能在整數秒時變化——最多可能與它指向的字詞相差一整秒,這看起來像是壞的。需要動畫的投疊需要約 30 fps。
如果快取幀,30 fps 是便宜的。 一個 60 秒的投影片有 1,800 幀,但幾乎所有都是像素相同的——發光只在淡入淡出期間移動。根據其視覺狀態鍵入每個幀,並為重複的幀建立硬連結:
key = (active_element, round(fade_alpha, 2), int(t)) # int(t) = 進度條的步進
if key in cache: os.link(cache[key], path); continue
每張投疊 120-200 個實際合成,而不是約 1,800 個,並且沒有額外的磁碟空間。但請先將進度條量化到整數秒——一個連續滑動的進度條會使每一幀都變得唯一,從而使快取完全失效。(在 1 fps 時它本來就是這樣運作的,所以沒有損失。)
在深色投疊上反轉變暗效果。 用半透明的白色沖洗非活動元素,在淺色投疊上會將它們推後,而在深色投疊上則會使它們變亮。為每張投疊攜帶 dim 顏色,而不是為整個簡報攜帶。
不要為說得太快的內容製作動畫。 在開始之前測量口語跨度:四個列表項目在大約 1.2 秒內快速說出,在 0.5 秒淡入淡出後只留下約 0.2 秒的發光,並且會閃爍。可以讓每個項目在旁白中擁有自己的節奏——這通常是一個改進,因為一口氣說完的清單是任何簡報中最密集的東西——或者讓投影片保持靜態。
常見錯誤
| 錯誤 | 後果 | 修正 |
|---|---|---|
ffmpeg drawbox 由 t 表達式驅動動畫 | 進度條在整個影片期間都顯示為已填滿 | 使用預烘焙的 PIL 幀方法 |
使用 Homebrew 的 ffmpeg 並帶有 drawtext | No such filter: 'drawtext' | 使用 static-ffmpeg(腳本會自動備援) |
使用 Homebrew 的 ffmpeg 並帶有 ass 濾波器 | No option name near '<filename>' | 檢查 libass 支援;使用 static-ffmpeg |
| 未對齊音訊片段就用於高亮 | 元素在錯誤的時間發光 | 執行 ElevenLabs 強制對齊;根據時間戳建立 hl.json |
| 在撰寫腳本前就製作動畫 | 旁白永遠無法完美配合 | 先錄製音訊,然後根據實際時間建立高亮 |
嵌入 force_style | 濾波器解析失敗 | 編輯 .ass 檔案並使用 ass= 濾波器 |
| 從已燒錄的影片重新編碼 | 第二個世代損失 + 無法移除文字 | 永遠從原始主影片檔案開始 |
| 在 1 fps 時檢查高亮 | 發光在字詞開始後最多 1 秒才出現 | 使用 --anim-fps 30 |