视频制作文字转语音PPT台词生成合成合并视频导出ffmpeg软件python
首先需要安装Python依赖库和用到的软件包:
| 库 | 用途 |
|---|---|
| `edge-tts` | 将备注台词转成语音 |
| `python-pptx` | 读取 PPT 页面和备注 |
| `pywin32` | 调用 Windows PowerPoint,替换音频并保存 PPT |
`aiohttp`、`lxml`、`Pillow` 等间接依赖会自动安装。其余导入项都是 Python 标准库。
**1. 新电脑准备好 Python,然后安装扩展**
当前电脑已验证的 Python 版本是 **3.13.9**。安装 Python 时勾选加入 PATH。
```powershell
python --version
python -m pip --version
```
执行以下安装命令:
```powershell
python -m pip install --index-url https://pypi.org/simple --target .tts_dependencies "edge-tts==7.3.1"
python -m pip install --index-url https://pypi.org/simple --target .ppt_dependencies "python-pptx==1.0.2"
python -m pip install --index-url https://pypi.org/simple pywin32
```
前两个库安装到项目自己的依赖目录,符合脚本现有的加载方式。
**迁移时建议只复制脚本、PPT 和所需资料,不复制旧电脑的 `.tts_dependencies`、`.ppt_dependencies`,在新电脑重新安装。** 这样可以避免 Python 版本或系统架构不一致。
**2. 还需要安装的非 Python 软件**
- **FFmpeg**:必须同时包含 `ffmpeg.exe` 和 `ffprobe.exe`。
- **Microsoft PowerPoint Windows 桌面版**:更新音频到 PPT 时需要;仅生成音频时不需要。
可以复制原电脑完整的 FFmpeg 工具目录。如果使用带 DLL 的版本,要连同 DLL 一起复制。
假设新电脑的 FFmpeg 位于 `C:\Tools\ffmpeg\bin`,在当前 PowerShell 中加入路径:
```powershell
$env:Path = 'C:\Tools\ffmpeg\bin;' + $env:Path
ffmpeg -version
ffprobe -version
```
这项设置只对当前窗口有效;长期使用可以将该 `bin` 目录添加到 Windows 的用户 PATH 环境变量。
**3. 检查 Python 扩展是否安装成功**
```powershell
python -c "import sys; sys.path[:0]=['.ppt_dependencies','.tts_dependencies']; import edge_tts; from pptx import Presentation; import win32com.client; print('Python dependencies OK')"
```
**4. 运行脚本**
例如,生成全部越南语音频并更新到新 PPT:
```powershell
python .\update_narration.py --ppt '.\TEST.pptx' --slides all --voice vi-VN-HoaiMyNeural --audio-dir '.\narration_updates_VN\m4a'
```
如果**只生成音频**,末尾加上:
```powershell
--stage audio
```
只生成音频时,需要 `edge-tts`、`python-pptx`、FFmpeg 和网络连接;完整更新到 PPT 时,还需要 `pywin32` 和 PowerPoint。运行完整更新前,请保存并关闭输入 PPT。
操作流程:
1 制作PPT,可以先手工截图所有页面保存至文件夹中
2 将所有截图插入到PPT中(可以让AI创建),并且生成各页的台词文本放到备注中。
3 执行脚本创建出音频:
进入PPT目录
Set-Location -LiteralPath 'D:\Desktop\day-work\企业视频\TEST-PPT-视频'
生成PPT的音频并嵌入PPT中
python .\update_narration.py --ppt '.\TEST.pptx' --slides all --voice zh-CN-XiaoxiaoNeural --audio-dir '.\narration_updates_CN\m4a'
说明:这条命令会读取全部 21 页的中文备注,生成 M4A、嵌入对应页面,并设置自动播放和换页。成品会带时间戳另存,原 PPT 保留,终端会显示新文件路径。
参数含义:
-
--slides all:处理全部页;只更新第 7 页时改为 --slides 7。
-
--voice zh-CN-XiaoxiaoNeural:普通话女声。
-
--audio-dir:中文音频独立保存,避免覆盖英文音频。
以后编辑新成品后,请将 --ppt 改为该成品的文件名。
4 合成视频:合并'主持人机器人介绍动画'视频到主视频中
E:\lumeng\soft\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe -i 1.mp4 -stream_loop -1 -i 2.mp4 -filter_complex "1:vscale=-2:160small;0:vsmalloverlay=W-w-0:H-h-0:shortest=1v" -map "v" -map 0:a? -c:v libx264 -preset medium -crf 20 -c:a copy -movflags +faststart -y output.mp4
关键参数:
-
scale=-2:120:人物高度 120px
-
W-w-20:距离右侧 20px
-
H-h-20:距离底部 20px
-
-stream_loop -1:人物视频循环播放
-
-y:自动覆盖原来的 output.mp4
5 压缩视频大小:
E:\lumeng\soft\ffmpeg-master-latest-win64-gpl-shared\bin\ffmpeg.exe -i output.mp4 -c:v libx264 -crf 35 -preset medium -c:a aac -b:a 128k output-small35.mp4
(越南语:
python .\update_narration.py --ppt '.\TEST_VN.pptx' --slides all --voice vi-VN-HoaiMyNeural --audio-dir '.\narration_updates_VN\m4a'
)
执行脚本:
"""Update selected slide narration from saved PowerPoint notes or UTF-8 TXT files.
Examples:
python update_narration.py --ppt deck.pptx --slides 7 --check
python update_narration.py --ppt deck.pptx --slides 7
python update_narration.py --ppt deck.pptx --slides 7,9-11 --stage audio
python update_narration.py --ppt deck.pptx --slides 7,9-11 --stage embed
Requires local .tts_dependencies and .ppt_dependencies, FFmpeg, and PowerPoint.
"""
from pathlib import Path
import argparse, asyncio, datetime, hashlib, json, os, re, shutil, subprocess, sys, tempfile, zipfile
BASE=Path(__file__).resolve().parent
for folder in ('.tts_dependencies','.ppt_dependencies'):
sys.path.insert(0,str(BASE/folder))
def pages(value,count):
result=set()
if value.lower()=='all':return list(range(1,count+1))
for part in value.split(','):
if not re.fullmatch(r'\d+(?:-\d+)?',part.strip()):raise ValueError('Use --slides 7 or 7,9-11 or all')
ends=[int(x) for x in part.strip().split('-')]
if len(ends)==2 and ends[0]>ends[1]:raise ValueError('Page ranges must be ascending')
result.update(range(ends[0],ends[-1]+1))
if not result or min(result)<1 or max(result)>count:raise ValueError(f'Pages must be between 1 and {count}')
return sorted(result)
def signature(text,voice,rate):return hashlib.sha256((voice+'\n'+rate+'\n'+text).encode('utf-8')).hexdigest()
def filehash(path):return hashlib.sha256(path.read_bytes()).hexdigest()
def probe(path):
info=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=format_name,duration:stream=codec_name','-of','json',str(path)]))
if 'mp4' not in info['format']['format_name'] or not any(x.get('codec_name')=='aac' for x in info['streams']):raise ValueError(f'Not AAC/M4A: {path}')
seconds=float(info['format']['duration'])
if seconds<=0:raise ValueError(f'Empty audio: {path}')
return seconds
def main():
parser=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--ppt',type=Path,required=True,help='Saved input PPTX. Save and close it first.')
parser.add_argument('--slides',required=True,help='Current page numbers, e.g. 7,9-11 or all')
parser.add_argument('--stage',choices=['all','audio','embed'],default='all')
parser.add_argument('--text-dir',type=Path,help='Optional UTF-8 slide_XX.txt directory; default: read PPT notes')
parser.add_argument('--audio-dir',type=Path,default=BASE/'narration_updates'/'m4a')
parser.add_argument('--output',type=Path,help='New output PPTX; default: timestamped copy beside input')
parser.add_argument('--voice',default='en-US-JennyNeural')
parser.add_argument('--rate',default='+0%')
parser.add_argument('--check',action='store_true',help='Read-only preflight; no network, no file writes')
args=parser.parse_args();args.ppt=args.ppt.resolve();args.audio_dir=args.audio_dir.resolve()
if not args.ppt.is_file():raise ValueError(f'Input does not exist: {args.ppt}')
from pptx import Presentation
ppt=Presentation(args.ppt);selected=pages(args.slides,len(ppt.slides));texts={}
for i in selected:
text=(args.text_dir/f'slide_{i:02d}.txt').read_text(encoding='utf-8-sig').strip() if args.text_dir else ppt.slides[i-1].notes_slide.notes_text_frame.text.strip()
if not text:raise ValueError(f'Page {i}: narration is empty')
texts[i]=text
print(f'Page {i:02d}: {len(text.split())} words; source: {"TXT" if args.text_dir else "PPT notes"}',flush=True)
if args.check:
print(f'Preflight OK: {len(ppt.slides)} slides, selected {selected}. No files changed.');return
for command in ('ffmpeg','ffprobe'):
if not shutil.which(command):raise RuntimeError(f'{command} not on PATH')
if args.stage!='audio':
target=args.output.resolve() if args.output else args.ppt.with_name(args.ppt.stem+'_narration_'+datetime.datetime.now().strftime('%Y%m%d_%H%M%S')+'.pptx')
if target==args.ppt or target.exists():raise ValueError('Output must be a new file; input is always preserved.')
# Require users to save/close the input so disk notes and generated audio agree.
if args.ppt.with_name('~$'+args.ppt.name).exists():raise RuntimeError('Please save and close the input PPT in PowerPoint, then run again.')
args.audio_dir.mkdir(parents=True,exist_ok=True)
manifest_path=args.audio_dir/'update_manifest.json'
manifest=json.loads(manifest_path.read_text(encoding='utf-8')) if manifest_path.exists() else {}
async def generate():
import edge_tts
for i in selected:
stem=f'slide_{i:02d}';dest=args.audio_dir/(stem+'.m4a')
with tempfile.TemporaryDirectory(prefix='eip_tts_') as td:
mp3=Path(td)/'speech.mp3';m4a=Path(td)/'speech.m4a'
for attempt in range(3):
try:
print(f'{stem}: generating audio ({attempt+1}/3)',flush=True)
await asyncio.wait_for(edge_tts.Communicate(texts[i],args.voice,rate=args.rate).save(str(mp3)),timeout=100);break
except Exception:
if attempt==2:raise
await asyncio.sleep(2*(attempt+1))
subprocess.run(['ffmpeg','-v','error','-y','-i',str(mp3),'-vn','-c:a','aac','-b:a','128k','-movflags','+faststart',str(m4a)],check=True)
seconds=probe(m4a)
subprocess.run(['ffmpeg','-v','error','-xerror','-i',str(m4a),'-f','null','-'],check=True)
staging=dest.with_suffix('.m4a.tmp');shutil.copyfile(m4a,staging);os.replace(staging,dest)
(args.audio_dir/(stem+'.txt')).write_text(texts[i]+'\n',encoding='utf-8')
manifest[stem]=dict(signature=signature(texts[i],args.voice,args.rate),audio_sha256=filehash(dest),seconds=seconds,voice=args.voice,rate=args.rate)
manifest_path.write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
print(f'{stem}: ready, {seconds:.3f}s',flush=True)
if args.stage in ('all','audio'):asyncio.run(generate())
if args.stage=='audio':
print('Audio ready:',args.audio_dir,'\nAfter listening, run the same command with --stage embed.');return
durations={}
for i in selected:
stem=f'slide_{i:02d}';path=args.audio_dir/(stem+'.m4a');record=manifest.get(stem,{})
if record.get('signature')!=signature(texts[i],args.voice,args.rate):raise ValueError(f'{stem}: text/voice changed. Generate audio again before embedding.')
if not path.exists() or record.get('audio_sha256')!=filehash(path):raise ValueError(f'{stem}: audio is missing or changed. Generate it again.')
durations[i]=probe(path)
import win32com.client
app=win32com.client.DispatchEx('PowerPoint.Application');presentation=None
try:
presentation=app.Presentations.Open(str(args.ppt),True,False,False)
if presentation.Slides.Count!=len(ppt.slides):raise RuntimeError('Slide count changed')
for i in selected:
slide=presentation.Slides(i)
for j in range(slide.Shapes.Count,0,-1):
sh=slide.Shapes(j)
if str(sh.Name).startswith('Narration_'):sh.Delete()
body=None
for j in range(1,slide.NotesPage.Shapes.Count+1):
sh=slide.NotesPage.Shapes(j)
if sh.Type==14 and sh.PlaceholderFormat.Type==2:body=sh;break
if body is None:raise RuntimeError(f'Page {i}: missing notes body')
body.TextFrame.TextRange.Text=texts[i]
audio=args.audio_dir/f'slide_{i:02d}.m4a'
shape=slide.Shapes.AddMediaObject2(str(audio),False,True,-100,-100,32,32)
shape.Name=f'Narration_Slide_{i:02d}';shape.Left=-100;shape.Top=-100;shape.Width=32;shape.Height=32
ps=shape.AnimationSettings.PlaySettings;ps.PlayOnEntry=True;ps.HideWhileNotPlaying=True;ps.LoopUntilStopped=False;ps.StopAfterSlides=1;ps.PauseAnimation=False
seq=slide.TimeLine.MainSequence
effects=[seq.Item(j) for j in range(1,seq.Count+1) if seq.Item(j).Shape.Id==shape.Id]
if not effects:effects=[seq.AddEffect(shape,83,0,2,1)]
for effect in effects:effect.Timing.TriggerType=2;effect.Timing.TriggerDelayTime=0;effect.MoveTo(1)
tr=slide.SlideShowTransition;tr.AdvanceOnClick=False;tr.AdvanceOnTime=True;tr.AdvanceTime=durations[i]+1
presentation.SlideShowSettings.AdvanceMode=2
presentation.SaveAs(str(target))
finally:
if presentation is not None:presentation.Close()
app.Quit()
saved=Presentation(target)
with zipfile.ZipFile(target) as z:
hashes={hashlib.sha256(z.read(n)).hexdigest() for n in z.namelist() if n.startswith('ppt/media/')}
for i in selected:
if saved.slides[i-1].notes_slide.notes_text_frame.text.strip()!=texts[i]:raise RuntimeError(f'Page {i}: notes verification failed')
if filehash(args.audio_dir/f'slide_{i:02d}.m4a') not in hashes:raise RuntimeError(f'Page {i}: embedded audio verification failed')
(target.with_suffix('.narration.json')).write_text(json.dumps(dict(source=str(args.ppt),output=str(target),slides=selected,durations=durations,verified=True),indent=2),encoding='utf-8')
print('Updated PPT:',target,flush=True)
if __name__=='__main__':main()