幻灯片转视频(slides + narration → 自动播放mp4)
概述
将N张幻灯片图像(或一个PDF) + N个每页旁白音频片段合成为一个mp4文件:每张幻灯片正好保持其旁白时长加上一个短暂的停顿,然后硬切到下一张。叠加层:一个填充进度条 + 剩余秒数倒计时(右下角)和一个“N / M”幻灯片计数器(右上角)。
核心命令(脚本位于本技能的 scripts/ 目录下——请使用其完整路径调用):
python3 <this-skill-dir>/scripts/build_video.py SLIDES AUDIO_DIR out.mp4
# SLIDES = slides.pdf(以200dpi分辨率栅格化)或一个每页幻灯片图像的目录
# (数字感知排序: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会替换字体并重排布局,因此视频将与幻灯片作者看到的界面不匹配。200dpi可确保1080p下密集的幻灯片文本保持清晰。(已经有每页幻灯片图像?直接使用它们——跳过此步骤。)
- 音频:每张幻灯片一个剪辑,命名为
slideNN.mp3。 如果剪辑来自 elevenlabs-tts 的generate_tts.py(narrating-pptx 使用它),它们已经符合此确切格式——重复使用它,以便pptx和视频听起来完全相同。 - 如果调整了旁白速度(例如
ffmpeg -filter:a atempo=1.1),请使用调整后的文件构建视频。
为什么叠加层使用PIL烘焙,而不是由ffmpeg绘制(血泪教训)
- 使用基于
t的宽度表达式的drawbox在某些ffmpeg构建中从第0帧就渲染一个完整的条——该表达式只在初始化时求值一次,而不是每帧求值。故障是静默的:视频正常编码,而进度条从未移动。在同一构建中,drawtext表达式确实会每帧更新,这使得损坏的进度条在一个正常工作的倒计时旁边很容易被忽略。 - Homebrew的ffmpeg瓶装包(2026年+)没有libfreetype —
No such filter: 'drawtext'— 而keg版本(ffmpeg@7、ffmpeg@8)也是如此。不要花费安装周期来发现这一点;如果因为其他原因需要完整构建,uv tool install static-ffmpeg提供了一个(脚本会自动回退到它)。
因此,脚本使用PIL烘焙帧(进度条填充、倒计时、计数器),并将图像序列以 -r 30 编码后提供给ffmpeg。确定性、字体可控,并且比过滤30fps流更便宜编码。默认是每秒一帧,并且在幻灯片上没有任何内容移动时是正确的——秒级更新对于倒计时来说阅读自然。携带--highlights的幻灯片则以30fps烘焙,原因在下一节中说明。
脚本已制定的编码选择(不要撤消它们)
| 选择 | 原因 |
|---|---|
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中进度条必须明显更满,倒计时更低——并且应该在每张新幻灯片时重置。在早期时间戳出现完整的进度条 = 静态进度条错误——正是这个检查最初发现了它。你无法无头验证音频——请说明这一点;永远不要声称你听到了。
突出显示与旁白同步的元素
说出“左边……中间……右边……”的旁白听起来像音频描述轨道(参见自动演示幻灯片)。解决方法是去掉口头指示,而是突出显示元素——但前提是时间来自音频。
音频是主导;动画跟随它。 永远不要为了击中某个标记而编写旁白,也永远不要估计短语何时出现。正常生成剪辑,然后使用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 <this-skill-dir>/scripts/make_highlights.py markers.json boxes.json narration/alignment/ hl.json \
--size 1920x1080 [--inches 13.333] # --inches: 框以幻灯片英寸为单位,页面宽度如此
# markers.json: {