用Python搭建AI视频自动化流水线:从脚本到成片
2026/9/7 2:25:54 网站建设 项目流程

做短视频或知识类视频时,最消耗时间的往往不是剪辑,而是“写脚本、找素材、配音、加字幕”这四个环节。很多人实际的工作流是:先在文档里写文案,再打开素材网站一页一页筛选下载,然后用配音工具逐句合成语音,最后在剪辑软件里手动对齐字幕。这样一期视频少则一两个小时,多则半天;如果文案中途改了一版,配音、字幕、时间轴全部要重来。

下面要搭建的是一条“输入主题,输出成片”的 AI 流水线:脚本由大模型生成,视频素材由脚本自动搜索下载,配音使用 TTS 合成,字幕根据音频时长生成,最后用 FFmpeg 完成视频拼接。这条流水线不是某个特定平台的功能,而是一组 Python 脚本加一个 Shell 调度文件。读者可以把它复制到本地跑通,也可以拆开替换其中的任意环节,适应自己的业务。

这篇文章适合三类读者:需要批量制作内容视频的运营或开发者;想了解大模型、TTS、字幕识别、FFmpeg 如何串成实际应用的工程师;以及刚接触自动化,想知道多环节任务应该如何拆分、如何排错的同学。读完以后,你能搭出一个最小可运行版本,并且知道每个环节最薄弱的点在什么地方。

1. 先把“做视频”拆成五个环节,再谈自动化

1.1 五个环节各自解决什么问题

自动化最难的不是写代码,而是拆任务。一个完整视频生产流程,可以拆成下面五个环节:

  • 脚本生成:根据主题生成标题和分段台词,同时给出每个分段的画面描述。
  • 素材获取:根据画面描述到素材库搜索并下载视频片段。
  • 配音合成:把每段台词转换成一段音频。
  • 字幕生成:生成带时间轴的 SRT 字幕文件。
  • 视频拼接:把素材、配音、字幕合成最终视频。

每个环节都有明确的输入和输出。脚本生成的输出是结构化 JSON;素材获取的输出是一批本地视频文件;配音合成的输出是每段音频;字幕生成的输出是 SRT 文件;视频拼接的输出是最终 MP4。

这样做的好处是:任何一步出错,只需要重跑那一步,不需要从头开始。比如配音生成失败,脚本和素材都已经存在,直接重新执行第三个脚本即可。

1.2 为什么每个环节要独立成脚本

如果把五个环节写进一个大文件,代码会非常长,而且很难排查。独立成脚本之后,可以单独测试每个环节,也可以单独替换某个实现。

例如脚本生成环节,今天用在线大模型 API,明天想换成私有化部署的模型,只需要改01_generate_script.py,下游环节完全不受影响。素材获取环节也是如此,Pexels 不支持某类素材时,可以替换成 Pixabay 或其他图库 API,上游脚本和下游合成脚本都不用动。

每个脚本采用相同的命令行风格,使用--script--audio-dir--output之类的参数传递路径。这样串联时非常直观,阅读日志时也能快速定位是哪个环节出了问题。

1.3 环节之间用文件和 JSON 传递数据

环境之间不通过全局变量传递数据,而是通过文件。最重要的中间文件是script.json,它定义了整条流水线的内容结构。

{ "title": "人工智能如何改变内容生产", "segments": [ { "index": 1, "scene": "城市夜景,霓虹灯闪烁", "content": "你可能没有注意到,你刷到的很多视频已经不是纯人工做出来的。" }, { "index": 2, "scene": "电脑屏幕上代码快速滚动", "content": "从脚本到配音,一条 AI 流水线正在接管大量重复劳动。" } ] }

segments列表是整条流水线的核心数据结构。index用于文件命名,scene是素材搜索时的关键词来源,content是配音和字幕的文本来源。

各环节的数据交接关系可以用下面这张表概括。

环节输入输出关键文件
脚本生成主题script.jsonwork/script.json
素材获取script.json视频片段work/materials/seg_01_0.mp4
配音合成script.json音频片段work/audio/seg_01.mp3
字幕生成script.json + 音频SRTwork/subtitle.srt
视频拼接素材 + 音频 + SRTMP4work/output.mp4

只要这几个文件路径稳定,流水线就能稳定工作。

2. 环境准备:Python、FFmpeg、API Key 三者缺一不可

2.1 依赖清单和安装方式

本地跑通这条流水线,需要准备三部分环境:Python 3.9 以上、FFmpeg 4.x 以上,以及一个可调用的大模型 API Key、一个素材库 API Key。

Python 依赖尽量保持精简,避免引入太重的东西。

requests>=2.31.0 edge-tts>=6.1.12

安装命令:

pip install -r requirements.txt

FFmpeg 不在 pip 依赖里,需要单独安装。macOS 可以用 Homebrew:

brew install ffmpeg

Ubuntu 或 Debian:

sudo apt update sudo apt install ffmpeg fonts-noto-cjk

其中fonts-noto-cjk是中文语言包,后面字幕渲染需要它,否则中文会显示成方块。

安装完成后检查版本:

ffmpeg -version ffprobe -version

如果命令行提示无法识别ffmpeg,说明 FFmpeg 没有安装,或者没有加入 PATH 环境变量。这类报错在 Windows 上尤其常见,安装后需要重启终端让环境变量生效。

2.2 项目目录结构

建议使用下面的目录结构,把脚本和中间产物分开。

video-pipeline/ ├── requirements.txt ├── config.py ├── pipeline.sh ├── scripts/ │ ├── 01_generate_script.py │ ├── 02_fetch_material.py │ ├── 03_generate_audio.py │ ├── 04_generate_subtitle.py │ └── 05_compose_video.py └── work/ ├── audio/ ├── materials/ └── output/

work目录用于存放所有中间产物。audiomaterials目录会自动创建。第一次运行时可以先手动创建work目录,也可以由脚本用os.makedirs自动创建。

2.3 公共配置通过环境变量注入

API Key、模型名称、配音音色这些参数不适合写死在代码里。这里建议集中放在一个config.py中,统一从环境变量读取。

import os LLM_API_BASE = os.getenv("LLM_API_BASE", "http://localhost:8000/v1") LLM_API_KEY = os.getenv("LLM_API_KEY", "") LLM_MODEL = os.getenv("LLM_MODEL", "qwen-plus") PEXELS_API_KEY = os.getenv("PEXELS_API_KEY", "") PEXELS_PAGE_SIZE = int(os.getenv("PEXELS_PAGE_SIZE", "10")) TTS_VOICE = os.getenv("TTS_VOICE", "zh-CN-YunxiNeural") TTS_RATE = os.getenv("TTS_RATE", "+0%") WORK_DIR = os.getenv("WORK_DIR", "work")

运行脚本时,通过环境变量注入配置:

export LLM_API_KEY="your-llm-key" export PEXELS_API_KEY="your-pexels-key" export TTS_VOICE="zh-CN-YunxiNeural" python scripts/01_generate_script.py --topic "人工智能如何改变内容生产"

本地开发可以放在.env文件里,但不要把.env提交到 Git。生产环境建议放到配置中心或容器环境变量中。

3. 第一步:用大模型生成结构化脚本

3.1 提示词决定后续链路的稳定性

脚本生成这一步,最关键的其实不是代码,而是提示词。大模型如果自由发挥,输出的可能是散文、表格、Markdown 标题,后续解析就会很麻烦。

所以提示词里需要明确三件事:角色是短视频脚本编辑;必须输出 JSON;JSON 的结构必须固定。

SYSTEM_PROMPT = """你是一个短视频脚本编辑。请根据用户给出的主题,生成一个适合制作短视频的脚本。 要求: 1. 脚本分为 6 到 10 个段落。 2. 每个段落包含独立的画面描述 scene 和配音台词 content。 3. 只返回 JSON,不要返回任何解释文字。 4. 不要使用 Markdown 代码块包裹 JSON。 5. JSON 结构示例: { "title": "视频标题", "segments": [ {"index": 1, "scene": "画面描述", "content": "配音台词"}, {"index": 2, "scene": "画面描述", "content": "配音台词"} ] } """

这里注意,提示词里强调“不要使用 Markdown 代码块包裹 JSON”,是因为很多模型会习惯性输出代码块,代码块外面再套一个json标记,会干扰json.loads解析。

3.2 调用兼容 OpenAI 的接口并解析 JSON

现在很多大模型服务都提供 OpenAI 兼容的 API,格式相同,替换时只需要改地址和模型名。下面代码演示如何调用/chat/completions接口,并把响应解析成字典。

import argparse import json import os import requests def call_llm(topic: str, api_base: str, api_key: str, model: str) -> str: url = f"{api_base}/chat/completions" headers = {"Authorization": f"Bearer {api_key}"} payload = { "model": model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"主题:{topic}"}, ], "temperature": 0.7, } resp = requests.post(url, json=payload, headers=headers, timeout=60) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"].strip()

解析响应时要做一层容错,把常见的 Markdown 代码块包裹去掉,再交给json.loads

def parse_script(raw: str) -> dict: raw = raw.strip() if raw.startswith("```"): raw = raw.strip("`") if raw.startswith("json"): raw = raw[len("json"):].strip() data = json.loads(raw) if "segments" not in data or not isinstance(data["segments"], list): raise ValueError("script.json 缺少 segments 列表") return data

主函数负责读取参数、调用模型、写文件。

def main(): parser = argparse.ArgumentParser() parser.add_argument("--topic", required=True, help="视频主题") parser.add_argument("--output", default="work/script.json", help="输出 JSON 路径") args = parser.parse_args() raw = call_llm( args.topic, os.getenv("LLM_API_BASE", "http://localhost:8000/v1"), os.getenv("LLM_API_KEY", ""), os.getenv("LLM_MODEL", "qwen-plus"), ) script = parse_script(raw) os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) with open(args.output, "w", encoding="utf-8") as f: json.dump(script, f, ensure_ascii=False, indent=2) print(json.dumps(script, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()

实际项目中建议把原始响应保存一份到日志文件,便于模型返回非 JSON 时排查。

3.3 脚本分段粒度对素材、配音、字幕的影响

分段数量直接决定后续素材下载、TTS 合成和字幕生成的规模。

如果分段太少,比如只有 3 段,每段台词会特别长,一个素材画面持续几十秒,视频看起来非常枯燥。如果分段太多,比如 20 段,每段素材下载、音频生成、字幕对齐都会成倍增加,而且单段文案太短,TTS 和字幕容易显得碎片化。

比较稳妥的范围是 6 到 10 段。每段台词控制在 40 到 80 个中文字符,这样单段视频时长在 10 到 20 秒之间,最终视频约 1 到 3 分钟,适合知识科普类内容。

这里还有一个容易踩的坑:index字段必须是整数,后续文件命名依赖它。如果模型返回的是字符串"1",在处理前先统一转成整数,避免文件名出现seg_01.mp3seg_1.mp3混用的问题。

4. 第二步:按脚本自动搜索并下载视频素材

4.1 素材源选型:优先用开放 API

找素材最常见的错误做法是直接写爬虫去抓网页上的视频,这样既不稳定,又有版权风险。更稳妥的方式是使用素材平台提供的开放 API。

以 Pexels 为例,它允许开发者通过 API 搜索免费可商用视频,使用时需要在官网申请 API Key,然后在请求头里带上:

Authorization: your_api_key

搜索接口是:

curl -H "Authorization: your_api_key" \ "https://api.pexels.com/videos/search?query=city&per_page=5&orientation=landscape"

返回结果里包含videos数组,每个视频对象中又有video_files数组,里面是不同的清晰度和格式地址。代码中需要从video_files里筛选宽度大于等于 1280、高度大于等于 720 的横屏视频。

4.2 从脚本里提取关键词并下载

本项目的素材关键词来自script.json中的scene字段。下载器遍历每个段落,用scene前几个字或整句去搜索,然后下载第一个符合条件的结果。

import json import time from pathlib import Path import requests def fetch_videos(keyword: str, api_key: str, per_page: int = 5) -> list: url = "https://api.pexels.com/videos/search" headers = {"Authorization": api_key} params = { "query": keyword, "per_page": per_page, "orientation": "landscape", } resp = requests.get(url, headers=headers, params=params, timeout=30) resp.raise_for_status() data = resp.json() result = [] for video in data.get("videos", []): for item in video.get("video_files", []): width = item.get("width") or 0 height = item.get("height") or 0 link = item.get("link") if link and width >= 1280 and height >= 720: result.append({"link": link, "width": width, "height": height}) break return result def download(url: str, target: Path): resp = requests.get(url, timeout=60) resp.raise_for_status() target.write_bytes(resp.content)

主函数中,文件名使用seg_{index:02d}_{序号}.mp4,这样后续拼接时可以根据文件名排序。

def main(): parser = argparse.ArgumentParser() parser.add_argument("--script", default="work/script.json") parser.add_argument("--material-dir", default="work/materials") args = parser.parse_args() material_dir = Path(args.material_dir) material_dir.mkdir(parents=True, exist_ok=True) script = json.loads(Path(args.script).read_text(encoding="utf-8")) api_key = __import__("config").PEXELS_API_KEY for seg in script["segments"]: keyword = seg["scene"][:10] try: videos = fetch_videos(keyword, api_key, per_page=5) except Exception as exc: print(f"seg {seg['index']} search failed: {exc}") continue if not videos: print(f"seg {seg['index']} no material") continue for seq, item in enumerate(videos[:1]): out_file = material_dir / f"seg_{seg['index']:02d}_{seq}.mp4" if out_file.exists() and out_file.stat().st_size > 1024: continue try: download(item["link"], out_file) print(f"seg {seg['index']} downloaded: {out_file}") except Exception as exc: print(f"seg {seg['index']} download failed: {exc}") time.sleep(1)

下载时强制间隔 1 秒,避免触发素材接口的频率限制。

4.3 素材文件命名和本地校验

素材下载不可靠是常态,尤其是不同地区网络差异较大时,下载可能超时或返回空文件。因此下载完成后要做两项校验:

  • 文件是否存在,且大小大于 1KB。
  • 是否能被 ffprobe 正常识别,文件头是否完整。

如果素材文件只有几字节或者无法识别,最好的策略是直接删除,让下一次运行重新下载。

另外,scene作为搜索关键词并不总是理想。比如“电脑屏幕上代码快速滚动”这种描述,素材平台很可能搜不到结果。工程上可以准备一份搜索词映射表:scene包含“代码”就搜programming,包含“城市”就搜city。这一步可以在扩展阶段补上。

5. 第三步:合成配音并生成字幕

5.1 edge-tts 把台词转成语音

配音环节使用edge-tts这个库。它调用微软 Edge 的在线 TTS 服务,支持多种中文音色,无需额外申请 API Key,对本地跑通非常友好。

import asyncio import json from pathlib import Path import edge_tts async def generate_one(text: str, voice: str, rate: str, out_file: Path): communicate = edge_tts.Communicate(text, voice=voice, rate=rate) await communicate.save(str(out_file)) def main(): parser = argparse.ArgumentParser() parser.add_argument("--script", default="work/script.json") parser.add_argument("--audio-dir", default="work/audio") args = parser.parse_args() audio_dir = Path(args.audio_dir) audio_dir.mkdir(parents=True, exist_ok=True) script = json.loads(Path(args.script).read_text(encoding="utf-8")) voice = __import__("config").TTS_VOICE rate = __import__("config").TTS_RATE for seg in script["segments"]: out_file = audio_dir / f"seg_{seg['index']:02d}.mp3" if out_file.exists() and out_file.stat().st_size > 0: continue asyncio.run(generate_one(seg["content"], voice, rate, out_file)) print(f"seg {seg['index']} audio done") if __name__ == "__main__": main()

edge-ttsrate参数用于控制语速,默认+0%。如果想读得快一点,可以设置成+10%+20%,但不要超过+30%,否则听感会比较仓促。

这里要注意一个坑:edge-tts输出的是 MP3 格式,不同段落的音频采样率可能不一致。如果后续拼接出现音画不同步,可以在合成视频时统一转成 44100Hz,或者先统一转成 WAV 再使用。

5.2 字幕生成的两条路线

字幕生成有两条技术路线,适用于不同精度要求。

路线 A:直接根据音频时长生成 SRT。每段字幕的显示时长等于对应音频的真实时长。优点是不需要安装额外的识别模型,速度快;缺点是不能做到逐句对齐。对于“一段画面配一段字幕”的短视频,这种方式已经够用。

路线 B:用 Whisper 识别语音生成逐句字幕。先合成整条音频,再交给faster-whisper识别,拿到每个句子的开始时间和结束时间。优点是准确;缺点是首次运行需要下载模型,而且识别出的文字可能和脚本原文不完全一致。

本文先实现路线 A。核心逻辑是:用 ffprobe 读出每段音频的时长,然后累计时间轴。

import json import subprocess from pathlib import Path def get_duration(path: Path) -> float: cmd = [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path), ] out = subprocess.check_output(cmd, text=True).strip() return float(out) def srt_time(seconds: float) -> str: hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) millis = int((seconds - int(seconds)) * 1000) return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" def build_srt(segments, audio_dir, srt_path): lines = [] cursor = 0.0 for seq, seg in enumerate(segments, start=1): audio_file = audio_dir / f"seg_{seg['index']:02d}.mp3" duration = get_duration(audio_file) start = cursor end = cursor + duration lines.append(str(seq)) lines.append(f"{srt_time(start)} --> {srt_time(end)}") lines.append(seg["content"]) lines.append("") cursor = end Path(srt_path).write_text("\n".join(lines), encoding="utf-8")

生成的 SRT 大致长这样:

1 00:00:00,000 --> 00:00:05,320 你可能没有注意到,你刷到的很多视频已经不是纯人工做出来的。 2 00:00:05,320 --> 00:00:12,110 从脚本到配音,一条 AI 流水线正在接管大量重复劳动。

5.3 时间轴怎么对齐:先有音频时长,再写 SRT

很多新手会犯一个错误:根据台词字数估算时间。比如认为 20 个字就是 10 秒,然后写死 SRT。但实际朗读速度、停顿、标点都会影响时长,估算很容易偏离。

正确做法是“先音频,后字幕”。字幕脚本在生成前,先用 ffprobe 获取每段音频的真实时长,再累加出每段字幕的起止时间。这样字幕总时长和配音总时长一定是吻合的。

如果要精度更高的逐句对齐,可以在路线 B 中使用faster-whisper

from faster_whisper import WhisperModel model = WhisperModel("small", device="cpu", compute_type="int8") segments, info = model.transcribe("output_audio.mp3", language="zh", vad_filter=True) for segment in segments: print(segment.start, segment.end, segment.text)

由于 Whisper 识别文本可能和原脚本有差异,工程上常用的做法是:以 Whisper 的时间戳为基准,但文字尽量用脚本原文;如果脚本台词和识别结果相差太大,说明 TTS 朗读的内容和脚本不一致,需要人工检查。

6. 第四步:用 FFmpeg 把素材、配音、字幕合成视频

6.1 先统一分辨率、帧率和编码

合成视频时,最大的问题是素材不一致。有的素材是竖屏,有的是横屏;有的是 25 帧,有的是 30 帧;有的编码是 H.264,有的是 H.265。这些不一致会导致拼接失败。

解决思路是:在每个段落合成时就统一参数。这里统一输出 1280x720 的横屏视频,H.264 编码,AAC 音频,YUV420P 像素格式。

scalecrop配合使用,可以把任意比例的素材缩放并裁剪到 1280x720,避免黑边。

scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720

force_original_aspect_ratio=increase的含义是先把画面等比放大到能够覆盖目标尺寸,再用crop居中裁剪掉多出来的部分。

6.2 单段合成命令解析

下面这段 Python 代码构造 FFmpeg 命令,把一个素材文件、一个音频文件和一个字幕文件合成为一个段落视频。

import os import subprocess from pathlib import Path def compose_segment(material, audio, srt, output, duration): cmd = [ "ffmpeg", "-y", "-stream_loop", "-1", "-i", str(material), "-i", str(audio), "-vf", "scale=1280:720:force_original_aspect_ratio=increase," "crop=1280:720," f"subtitles={srt.name}:force_style='FontName=Noto Sans CJK SC,FontSize=20'", "-t", f"{duration:.3f}", "-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-pix_fmt", "yuv420p", str(output), ] subprocess.run(cmd, check=True)

这里有几个关键点:

  • -stream_loop -1让素材循环播放,当素材时间短于音频时长时补齐画面。
  • -t duration截断输出,保证每段视频时长等于配音时长。
  • subtitles滤镜读取 SRT 文件。force_style中的字体名必须是系统里真实存在的中文字体。
  • 音频采样率统一指定为 44100,避免后续拼接采样率不一致。

subtitles滤镜对路径非常敏感。如果传入绝对路径,Windows 下的盘符冒号会被 FFmpeg 误解析。稳妥做法是:运行前先切换到 SRT 所在目录,然后在命令中只使用文件名。

os.chdir(srt.parent)

6.3 多段拼接

每个段落都合成成功后,使用 concat demuxer 进行拼接。

def concat_segments(seg_files, output): concat_file = output.parent / "concat.txt" content = "\n".join(f"file '{p}'" for p in seg_files) concat_file.write_text(content, encoding="utf-8") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), "-c", "copy", str(output), ] subprocess.run(cmd, check=True)

concat demuxer 的原理是把多个视频文件的编码流直接复制到输出文件,不做重新编码,速度非常快。前提是每个段的编码参数完全一致。所以单段合成时,必须保证分辨率、帧率、像素格式、音频采样率、编码器都一样。

6.4 用 ffprobe 验证输出

合成完成后,不能只看文件是否生成,还要确认视频流和音频流是否正常。

ffprobe -v error -show_entries stream=codec_type,codec_name,width,height -of compact output.mp4

正常输出应包含:

stream|codec_type=video|codec_name=h264|width=1280|height=720 stream|codec_type=audio|codec_name=aac

再看总时长和大文件大小:

ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 output.mp4

如果总时长明显小于配音总时长,说明某个环节的-t参数或 SRT 时间轴写错了。

注意:只验证“FFmpeg 命令返回 0”是不够的,还要用 ffprobe 检查输出文件里的视频流、音频流、分辨率、时长是否和预期一致。

7. 第五步:用 pipeline.sh 串联整条流水线

7.1 shell 脚本的职责

各环节单独能跑通之后,再用一个 Shell 脚本把它们串起来。pipeline.sh的职责是:接收主题,按固定顺序执行五个 Python 脚本,并在日志中输出进度。

#!/usr/bin/env bash set -euo pipefail TOPIC="${1:-人工智能如何改变内容生产}" WORK_DIR="work" echo "[pipeline] 01 generate script" python scripts/01_generate_script.py --topic "$TOPIC" --output "$WORK_DIR/script.json" echo "[pipeline] 02 fetch material" python scripts/02_fetch_material.py --script "$WORK_DIR/script.json" --material-dir "$WORK_DIR/materials" echo "[pipeline] 03 generate audio" python scripts/03_generate_audio.py --script "$WORK_DIR/script.json" --audio-dir "$WORK_DIR/audio" echo "[pipeline] 04 generate subtitle" python scripts/04_generate_subtitle.py --script "$WORK_DIR/script.json" --audio-dir "$WORK_DIR/audio" --srt "$WORK_DIR/subtitle.srt" echo "[pipeline] 05 compose video" python scripts/05_compose_video.py \ --script "$WORK_DIR/script.json" \ --material-dir "$WORK_DIR/materials" \ --audio-dir "$WORK_DIR/audio" \ --srt "$WORK_DIR/subtitle.srt" \ --output "$WORK_DIR/output.mp4" echo "[pipeline] done: $WORK_DIR/output.mp4"

set -euo pipefail是 Shell 脚本里的安全开关,含义是:

  • -e:任何一个命令返回非 0 状态,脚本立即退出。
  • -u:使用未定义的变量时报错。
  • -o pipefail:管道中任何一条命令失败,整条管道失败。

加上这套配置,流水线里任意一步失败,后续步骤都不会执行,可以快速定位失败点。

7.2 幂等性处理

重复执行流水线时,要避免重复下载素材和重复生成音频。各脚本应该在文件生成前检查“文件是否已经存在且大小大于 0”,满足条件就跳过。

例如03_generate_audio.py里的判断:

out_file = audio_dir / f"seg_{seg['index']:02d}.mp3" if out_file.exists() and out_file.stat().st_size > 0: continue

但幂等判断也有副作用:如果脚本内容修改了,旧文件会导致更新不生效。此时可以提供一个--clean参数,执行前清空work目录中的中间产物。

rm -rf work/audio work/materials work/subtitle.srt work/output.mp4

生产环境还可以引入“根据文件生成时间和 script.json 修改时间判断是否重跑”的策略。

7.3 日志与失败定位

Shell 脚本中直接使用echo打印进度,已经能满足最小版本的需要。如果要长时间运行,建议把日志重定向到文件:

bash pipeline.sh "话题名称" > run.log 2>&1

这样即使终端关闭,也能查看执行记录。每个 Python 脚本内部已经用print打印了关键阶段,比如“seg 3 downloaded”“seg 5 audio done”。日志里如果停止在某一行,问题大概率就在那一个环节。

8. 常见问题排查:从现象倒推根因

8.1 排查顺序

多环节流水线的排错,最好按照数据流向逐层检查。顺序是:

  1. work/script.json是否存在,segments是否为空。
  2. work/materials下的素材文件是否存在,大小是否正常。
  3. work/audio下的音频文件是否生成,时长是否合理。
  4. work/subtitle.srt时间轴是否连续,有没有重叠或空行。
  5. FFmpeg 日志报了什么错误,输出文件是否完整。
  6. 最终输出能否被播放器正常打开。

其中任何一层异常,都优先修复上一层,而不是反复重跑下一层。

下面这张表整理了几类高频问题。

问题现象常见原因检查方式处理建议
大模型返回内容解析失败模型返回了 Markdown 或非 JSON打印原始响应并保存到日志提示词强调只返回 JSON;部分接口支持response_format;重试一次
素材下载 403 或超时API Key 无效、链接过期、触发频率限制curl -I检查链接状态码校验 Key;下载之间加延时;失败换下一个素材
音频文件为空或时长异常台词为空、TTS 在线服务异常检查文件大小并用播放器试听跳过空文本;单独执行一句 TTS 验证网络
中文字幕显示为方块系统缺少中文字体执行fc-list :lang=zh安装fonts-noto-cjk,并确认FontName与字体实际名称一致
concat 拼接失败各段分辨率、编码、帧率不一致对比每一段的 ffprobe 信息单段合同时统一编码参数;必要时改为重新编码拼接
重复运行产生混乱文件缺少幂等判断查看work目录文件时间生成前判断文件存在且大于 0;支持--clean清空中间产物

8.2 三个最容易踩的坑

第一个坑是把素材下载 URL 直接交给 FFmpeg。素材平台返回的视频地址通常带有临时签名和查询参数,FFmpeg 解析这种地址容易失败。正确做法是先用requests下载到本地,校验文件大小,再交给 FFmpeg。

第二个坑是字幕时间轴按字数估算。字数不能准确反映朗读时长,尤其是标点、停顿、语气词都会影响实际时间。正确做法是先合成音频,再用ffprobe读取音频真实时长,最后累加生成 SRT。

第三个坑是忽略subtitles滤镜的路径转义。FFmpeg 每个滤镜参数都有自己的一套转义规则,路径里的冒号和反斜杠容易造成解析错误。最稳妥的方案是执行前os.chdir(srt.parent),命令中使用相对文件名。遇到 Windows 路径时,尤其要注意这一点。

注意:不要把“日志最后一行出现done”当成流水线成功。真正的成功标准应该是:输出视频能被播放器打开,视频流和音频流都存在,总时长和配音总时长一致,中文字幕渲染正常。

9. 学习环境与生产环境差异,以及扩展方向

9.1 本地跑通与生产部署的差异

本地用 Shell 脚本串联五个 Python 文件,适合学习和验证。进入生产环境后,这套同步脚本会暴露出不少问题:没有失败重试、没有任务队列、中间文件占据大量磁盘、没有监控告警。

下面是本地环境和生产环境的主要差异。

维度本地跑通生产部署
调度方式pipeline.sh 手动执行消息队列或任务平台调度,失败自动重试
存储本地 work 目录对象存储加元数据数据库
模型调用单次同步请求异步任务、限流、降级、超时控制
素材下载逐个 requests 下载预下载、重试队列、文件完整性检测
字幕精度按音频时长估算Whisper 识别加人工抽样审核
日志print 输出到终端结构化 JSON 日志加监控告警
内容审核无或人工观察关键词过滤、敏感信息识别、人工审核流程

如果只在本地生成少量视频,直接使用下面的方案没有问题。如果要支撑一个自动化视频平台,生产环境至少要引入任务队列、对象存储和审核机制。

9.2 可落地的扩展方向

流水线跑通后,扩展空间比较大。这里提供几个方向,按投资回报率排序。

第一个方向是增加封面生成。可以从最终视频中抽取关键帧作为封面,也可以用标题调用图像生成接口制作封面图,逻辑上是在05_compose_video.py之后加一个封面模块。

第二个方向是批量生产。把“输入一个主题”改成“输入一个主题列表”,由 Shell 脚本循环执行。批量场景下必须引入任务状态记录,记录每个主题走到哪个环节、成功还是失败。

第三个方向是引入 Agent 编排。当前脚本的关键词来自scene字段,比较机械。如果使用 LangChain 或 Dify 这类平台搭建 Agent,可以让模型根据段落内容自主判断搜索关键词、调整段落长度,甚至判断素材是否匹配。

第四个方向是沉淀知识库。历史脚本、爆款主题、素材来源、发布数据都可以整理进知识库。后续生成脚本时,先检索知识库中同类内容的写法,再生成新脚本,内容质量会更稳定。

9.3 内容安全和版权检查不能省

自动化程度越高,越要重视内容和版权检查。

素材版权方面,只使用素材平台明确标注可商用的资源,并保留每个素材的授权信息和出处。AI 生成的文案在发布前必须经过敏感词过滤和人工审核,不能直接把模型输出发布出去。

配音方面,本方案只使用公开 TTS 音色。不要使用未经授权的声音克隆技术生成他人音色,否则会带来肖像权和声音权的合规风险。

发布方面,不同平台对 AI 生成内容的规则不一致。接入自动发布功能前,要重新确认目标平台的开放接口、频率限制和内容规范。

最后给一个建议:第一次跑通时,不要太早追求“全自动”。先用命令单独执行每个脚本,把script.json、素材文件、音频文件、SRT、最终 MP4 逐个手工检查一遍,理解每个中间文件是怎么来的。当你能用一条命令把主题变成成片之后,再逐步加入批量生产、任务队列和审核环节。那时候,这条流水线就不是一个示例,而是你后续做内容工具的脚手架。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询