Communitygithub.com

shaharsha/deck-to-video

將投影片與逐頁旁白音訊合成為自動播放的 MP4 影片,包含進度條、倒數計時與投影片計數器。

deck-to-video 是什麼?

此技能用於將一組投影片圖片(或 PDF)與對應的逐頁旁白音訊,轉換為一支可分享的 MP4 影片。它會在每張投影片上疊加一個會隨著時間填滿的進度條、秒數倒數計時器(右下角)、以及「第 N / M 頁」投影片計數器(右上角)。核心邏輯由 `scripts/build_video.py` 處理,透過 PIL (Python Imaging Library) 預先繪製每一幀的疊加層,再交由 ffmpeg 編碼成影片序列——這項設計是為了避開 ffmpeg 在不同系統(如 Homebrew)中常見的 `drawtext` 或 libfreetype 遺失等臭蟲。使用者可以啟用 `--highlights` 參數,根據旁白實際唸到的時間點(透過 ElevenLabs 強制對齊取得),為投影片上的特定元素添加發光或變暗效果。還支援燒錄或封裝字幕(SRT/ASS),並提供多種嵌入方式(側載、軟軌或永久燒錄),以確保在 Slack 或 Teams 這類內嵌播放器中也能正常顯示。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/shaharsha/claude-skills/tree/HEAD/skills/deck-to-video

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

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 結束(否則會以非零狀態碼結束)。

準備正確的輸入

  1. 投影片:如果從 pptx 開始,請透過真正的 PowerPoint 進行光柵化,而不是 LibreOffice。 透過實際的 PowerPoint 將 pptx 匯出為 PDF(office-render 技能可在 macOS 上執行此操作),然後將 PDF 交給腳本——LibreOffice 會替換字型並重新排列版面,因此影片將與簡報作者所見的內容不符。200 dpi 可在 1080p 下保持密集投影片文字的清晰度。(已有逐頁圖片?直接使用它們——跳過此步驟。)
  2. 音訊:每張投疊一個片段,命名為 slideNN.mp3。 如果片段來自 elevenlabs-tts 的 generate_tts.py(narrating-pptx 使用此工具),它們已經是這種確切格式——重複使用它,這樣 pptx 和影片聽起來就會一致。
  3. 如果旁白速度已被調整(例如 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 並帶有 drawtextNo 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

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

shaharsha/generating-video-clips

Use when turning a still image, illustration, comic panel or character art into a short animated clip (image-to-video), choosing a video model or provider (Gemini Omni, Veo, Seedance, Kling, ElevenLabs Flows) or hitting their API gates, lip-syncing a character's mouth to existing dialogue audio (Sync, fal.ai, active speaker), or fixing a generated clip with artifacts - duplicated props, a door or a second figure appearing, hands through bodies, glowing eyes, a gesture repeated at the start,...

shaharsha/image-generation

Generate logos, icons, UI mockups, hero images, product shots, and other design assets using OpenAI GPT Image 2.5 (Sunburst, Flare) or Google Gemini (Nano Banana 2, Nano Banana Pro, Nano Banana 2 Lite). Use whenever the user asks to create, design, generate, mock up, render, or illustrate a visual asset, needs a transparent PNG, or asks for image prompt engineering. Picks the right model and quality for the job, writes a model-specific prompt following the official prompting rules of each...

shaharsha/producing-animated-episodes

Use when making a narrated or voiced animated video with recurring characters - an anime, cartoon or comic episode, a short for a group chat, a story video about friends, family, a class or a team - from photos, a chat export, a story idea or a script, in any visual style, Hebrew/RTL or English. Also use when such a video has subtitles or cards too fast to read, music stopping early, the ending cut off, lines buried under music, mouths out of sync, a gesture before its line, or one face...

相關技能