在单台 VPS 上搭建 AI 视频生成工作流(二):接入 LLM 和 TTS,实现全自动
从 Part 1 结束的地方开始
Part 1 完成后,你有:
- 一个 Flask 视频合成服务(
video_service.py),输入题目,输出 12 秒的 MP4 - 3 张静态占位图
- n8n 里一个能触发视频生成的工作流
但视频有两个明显问题:
- 没有配音 —— 只是图片幻灯片,观众听不到内容
- 脚本是硬编码的 —— 每次要手动改题目,没有真正的 AI 生成
这篇解决这两个问题。
架构升级
Part 1 的流程:
text
n8n → 视频服务 → MP4
Part 2 的流程:
text
n8n (Manual Trigger) → HTTP Request: 调 One API → NVIDIA LLM 生成脚本 → HTTP Request: 调视频服务(传脚本 + 题目) → 视频服务: edge-tts 生成配音 + MoviePy 合成 → 带配音的 MP4
多了一个”生成脚本”的环节,视频服务也多了”生成配音”的能力。
第一步:接入 LLM 生成脚本
你的 VPS 上已经有一个 Docker 版的 One API,统一管理 LLM 渠道。关键是把 One API 的地址暴露给 n8n 容器。
打通 n8n 到 One API 的网络
n8n 是 Docker 容器,访问宿主机的服务需要走 Docker 网桥。之前已经遇到过这个问题:n8n 访问 172.17.0.1(Docker 网桥)被 iptables 拦截。
解决方案是放行 Docker 网段到宿主机的流量:
bash
iptables -I INPUT 1 -s 172.17.0.0/16 -p tcp --dport 3000 -j ACCEPT iptables -I INPUT 1 -s 172.18.0.0/16 -p tcp --dport 3000 -j ACCEPT iptables -I INPUT 1 -s 172.19.0.0/16 -p tcp --dport 3000 -j ACCEPT iptables -I INPUT 1 -s 172.20.0.0/16 -p tcp --dport 3000 -j ACCEPT
同样的规则也要给视频服务的 5001 端口。保存规则:
bash
apt install -y iptables-persistent netfilter-persistent save
选择 LLM 模型
NVIDIA 免费层提供了多个模型。测试下来,nvidia/nemotron-3-ultra-550b-a55b 效果最好,但它是推理模型——会先生成一段”思考过程”(reasoning_content),然后才输出正式回答(content)。
踩过的坑:
max_tokens=1024不够 —— 推理过程吃掉大部分 token,content被截断成十几个字符- 解决:把
max_tokens提到 4096 - 即使不显式传
chat_template_kwargs,模型默认仍然会推理
n8n 节点配置
节点 2:HTTP Request
- Method:
POST - URL:
http://172.17.0.1:3000/v1/chat/completions - Headers:
Authorization: Bearer sk-你的OneAPI令牌 - Options → Timeout:
300000 - Body (JSON):
json
{
"model": "nvidia/nemotron-3-ultra-550b-a55b",
"messages": [
{
"role": "user",
"content": "为主题「AI改变教育」写一段 30 秒 YouTube Shorts 脚本,包含开场钩子、正文要点、结尾 CTA。总字数控制在 130 字以内。只输出脚本内容。"
}
],
"max_tokens": 4096,
"temperature": 0.7,
"stream": false
}
关键点:
max_tokens: 4096—— 给推理模型留足预算stream: false—— 关闭流式,避免 n8n 的超时问题- Timeout
300000—— 推理模型响应约 37 秒,默认 18 秒会超时
第二步:加 TTS 配音
用 edge-tts,完全免费、无需 API Key、中文效果自然。
安装
bash
cd /opt/rag-nvidia source venv/bin/activate pip install edge-tts
中文音色
| 音色 ID | 名称 | 特点 |
|---|---|---|
zh-CN-YunxiNeural | 云希 | 男声,阳光活力,适合短视频 |
zh-CN-XiaoxiaoNeural | 晓晓 | 女声,温暖自然 |
zh-CN-YunjianNeural | 云健 | 男声,激情有力 |
zh-CN-YunyangNeural | 云扬 | 男声,专业可靠 |
推荐 zh-CN-YunxiNeural。
更新 video_service.py
关键改动是:视频时长由音频决定,而不是固定的 12 秒。
python
import edge_tts
import asyncio
from moviepy import AudioFileClip
async def generate_tts(text, output_path, voice="zh-CN-YunxiNeural"):
communicate = edge_tts.Communicate(text=text, voice=voice)
await communicate.save(output_path)
@app.route("/generate", methods=["POST"])
def generate():
data = request.get_json() or {}
topic = data.get("topic", "AI")
script = data.get("script", "")
job_id = f"video_{int(time.time())}"
workdir = OUTPUT_DIR / job_id
workdir.mkdir(exist_ok=True)
# 1. TTS 配音
audio_path = workdir / "voice.mp3"
tts_text = script if script else f"今天我们来聊聊{topic}"
asyncio.run(generate_tts(tts_text, str(audio_path)))
audio_clip = AudioFileClip(str(audio_path))
audio_duration = audio_clip.duration
# 2. 每张图片的时长 = 音频总时长 / 图片数量
per_image = audio_duration / len(STATIC_IMAGES)
clips = []
for img in STATIC_IMAGES:
clip = ImageClip(img).with_duration(per_image).with_effects(
[vfx.FadeIn(0.5), vfx.FadeOut(0.5)]
)
clips.append(clip)
video = concatenate_videoclips(clips, method="compose")
# 3. 合并音频
final = video.with_audio(audio_clip)
out_path = workdir / "output.mp4"
final.write_videofile(
str(out_path), fps=24, codec="libx264",
audio_codec="aac", logger=None,
)
video.close()
audio_clip.close()
return jsonify({
"job_id": job_id,
"topic": topic,
"video_path": str(out_path),
"script_length": len(script),
"video_duration": audio_duration,
})
一个容易踩的坑:视频和音频时长不匹配
之前的版本是:
python
if audio_clip.duration > video.duration:
audio_clip = audio_clip.subclipped(0, video.duration) # 音频被裁剪
else:
video = video.with_duration(audio_clip.duration) # 视频拉长
问题在于:当脚本很长(音频 90 秒),而视频固定 12 秒时,走了第一条分支——音频被砍到 12 秒,后面 78 秒内容全丢。
修复:让图片的时长动态计算,per_image = audio_duration / len(STATIC_IMAGES),这样视频长度总是匹配音频。
第三步:n8n 传递脚本
节点 3(视频生成)的 Body 要接收节点 2 的输出。
不要用”Using JSON”手写 —— 节点 2 的脚本里有换行符,直接插入 JSON 字符串会报 Bad control character in string literal。
用”Using Fields Below”:
topic:AI改变教育script: 点fx按钮,选节点 2 的输出字段
表达式语法:
text
{{ $('oneapi LLM').item.json.choices[0].message.content }}
注意 $('oneapi LLM') 里的名字要和 n8n 画布上节点 2 显示的名字完全一致。
完整工作流
text
Manual Trigger
↓
HTTP Request (oneapi LLM)
- 调 One API,生成脚本
- 约 37 秒
↓
HTTP Request (视频生成)
- 传 topic + script
- Timeout 300000
- 约 1-2 分钟
↓
/opt/rag-nvidia/videos/video_xxx/output.mp4
实测结果
输入:题目 AI改变教育
节点 2 输出(97 字脚本):
text
开场:作业还在愁?AI秒批、个性化推题!正文:1.智能批改释放老师时间;2.自适应路径让每个孩子按节奏进步;3.虚拟导师24h陪伴,打破时空限制。结尾:关注我,获取更多AI教育黑科技!
节点 3 输出:
json
{
"job_id": "video_1789380608",
"script_length": 97,
"topic": "AI改变教育",
"video_duration": 23.02,
"video_path": "/opt/rag-nvidia/videos/video_1789380608/output.mp4"
}
视频文件:
text
voice.mp3 135K edge-tts 生成的 23 秒配音 output.mp4 2.2M 带配音的完整视频 ffprobe: codec_name=h264, codec_type=video, duration=23.00s codec_name=aac, codec_type=audio, duration=23.02s
从点击到拿到视频,全程约 2-3 分钟,完全自动。
踩坑记录
1. 推理模型的 token 预算nemotron-3-ultra 是推理模型,reasoning_content 会消耗大量 token。max_tokens=1024 时 content 被截断成十几个字符。必须给到 4096。
2. n8n 的默认超时是 18 秒
不是 180 秒。改成 300000(300 秒 = 5 分钟),单位是毫秒。
3. Docker 容器访问宿主机的 iptables 问题
K3s 和 UFW 都在管理 iptables,INPUT 链 policy 是 DROP。Docker 容器到宿主机的流量被拦。解决:iptables -I INPUT 1 -s 172.17.0.0/16 -p tcp --dport <端口> -j ACCEPT。
4. n8n 的 JSON Body 不接受换行符
用”Using Fields Below”代替”Using JSON”,让 n8n 自己处理转义。
5. 视频和音频时长不匹配
如果音频比视频长,且代码直接裁剪音频,长脚本会被截断。正确做法是让图片时长动态计算,匹配音频。
6. n8n 的 Execute Workflow 会自动跑整条链
前提是节点之间有连接。如果只跑了一个节点,检查节点间的箭头是否连上。
当前状态
| 组件 | 状态 |
|---|---|
| n8n 编排 | ✅ 5678 |
| One API 网关 | ✅ 172.17.0.1:3000 |
| NVIDIA LLM | ✅ nemotron-3-ultra-550b |
| edge-tts 配音 | ✅ 云希男声 |
| 视频合成 | ✅ MoviePy + FFmpeg |
| 音视频同步 | ✅ 时长自动匹配 |
| 输出 | ✅ 23 秒带配音 MP4 |
下一步
Part 2 完成了”题目 → 带配音视频”的全自动链路。Part 3 会做:
- Webhook 触发 —— 用 URL 传题目,实现真正的”输入即生成”
- 加字幕 —— 用 edge-tts 的时间戳把文字烧录到画面
- YouTube 上传 —— 配置 OAuth2,自动发布
- 动态图片 —— 用脚本驱动图片生成,替换静态占位图
