视频制作文字转语音PPT台词生成合成合并视频导出ffmpeg软件python edge-tts

视频制作文字转语音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()
相关推荐
MSTcheng.1 小时前
Agent 跑到一半崩了,怎么从上一步接着跑?LangGraph StateGraph 实战:状态持久化、断点恢复与人工审批
python·状态管理·ai agent·langgraph·断点恢复·人工审批
略略略咯咯1 小时前
33333
开发语言·windows·python
java_prinln1 小时前
11. 说说 Single-Agent 和 Multi-Agent 的设计方案?
python
垆边人似月.1 小时前
第三篇:函数、文件与异常处理 / Functions, Files & Exception Handling
开发语言·python
我的xiaodoujiao1 小时前
Django 权限系统项目实战详细图文教程 4--系统登录功能实现 2
后端·python·django
鬼手点金1 小时前
缓存作为架构约束
开发语言·前端·人工智能·python·microsoft·缓存·架构
恩喜玛生物2 小时前
AIDD系列02:ProteinMPNN序列设计,从已知骨架到可检验的肽候选
python·ai
FelixZhang0282 小时前
不懂金融或不会编程,如何开始量化?从策略搭建到回测验证
人工智能·python·算法·数学建模·均值算法·自然语言处理·金融
打工仔折腾 AI2 小时前
后端部署避坑:吃透IPv4地址、网络接口与CIDR前缀的实际应用
arm开发·人工智能·后端·python·性能优化