Article → Video Production Pipeline
核心理念
文章转视频不是"翻译",是"再创作"。 微信文章是读的,视频是看+听的。好的转换应该让观众"听+看一个独立的故事",不是"瞪着眼睛读屏幕上的字"。
质量标准(四必须)
| 标准 | 说明 |
|---|---|
| 🥇 必须有声音 | TTS 旁白 + 背景配乐。纯文字幻灯片 = 废品 |
| 🥈 数字要动态 | 数据从低滚到高,不要静态表格。大字率 > 小表格 |
| 🥉 开头有钩子 | 前 3 秒痛点/反常识/具体数字,不要放标题 |
| 4 | 节奏随内容 |
Pipeline
Step 0: 图片源(Phase 3a — 真实照片背景) + Fast Mode 渲染
📄 详见:
references/pexels-integration.md
当分镜的 visual_type 为 ai_concept_art 或 corporate_visual 时,自动从 Pexels 搜索真实照片作为背景,叠加 60% 暗色遮罩 + 1.5px 高斯模糊 + 文字卡。
能力矩阵:Pexels → ai_concept_art / corporate_visual;PIL 原渲染 → gradient_text / cinematic_text / tech_abstract / motion_infographic
关键 Pitfall:Pexels API 要求 User-Agent header(无则 403);中文搜索返回 0 结果,必须用英文关键词;下载 CDN 图片同样需要 UA。
Fast Mode 渲染(默认):每场景只渲染 1 帧关键帧,encoder.frame_to_mp4() 用 ffmpeg -loop 1 -tune stillimage 循环编码。8 场景 ~3 分钟(vs 逐帧渲染 ~25 分钟)。如需逐帧动效改回 frames_to_mp4()。
Step 1: 写分镜(最关键)
每帧定义:text(旁白文字)、big_text(屏幕大标题)、subtext(副标题/补充)、bg_color(背景色)、duration_extra(留白秒数)。
原则:
- 7 帧是黄金数量(60-90 秒视频)
- 每帧 1-2 个核心信息点
- big_text < 25 字,大字率
- 帧号标记(01/07 → 07/07)放在右上角
- 最后帧必须有 CTA(GitHub 链接/公众号关注)
Step 2: TTS 旁白
首选 Edge TTS(免费,零配置):
import edge_tts, asyncio
comm = edge_tts.Communicate(text, "zh-CN-XiaoxiaoNeural") # 自然女声
asyncio.run(comm.save("output.mp3"))
可选语音:zh-CN-YunxiNeural(男声)、zh-CN-XiaoyiNeural(活泼女声)。
MiniMax TTS(需 JWT 格式 key,sk-cp- 前缀不可用):
# MiniMax key 必须是 eyJ... JWT 格式
# sk-cp-... 格式会返回 "login fail"
url = "https://api.minimax.chat/v1/t2a_v2"
# voice_id: "female-shaonv", "male-qn-qingse", etc.
Step 3: 渲染视频帧
首选:ffmpeg drawtext
ffmpeg -y \
-f lavfi -i "color=c=#0f0c29:s=1080x1920:d=8.5:r=30" \
-i scene_01.mp3 \
-vf "drawtext=text='主标题':fontsize=52:fontcolor=white:x=(w-tw)/2:y=h*0.3:fontfile=PingFang.ttc:shadowcolor=black@0.5:shadowx=3:shadowy=3,..." \
-c:v libx264 -preset fast -crf 23 \
-c:a aac -b:a 128k -shortest segment_01.mp4
⚠️ macOS Homebrew ffmpeg 默认不带 drawtext(缺 libfreetype)。检测:ffmpeg -filters 2>&1 | grep drawtext。无输出 = 不可用。
Fallback:PIL/Pillow 逐帧渲染(本会话验证通过)
from PIL import Image, ImageDraw, ImageFont
FONT = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 52)
for fnum in range(total_frames):
img = Image.new("RGB", (1080, 1920), (15, 12, 41))
draw = ImageDraw.Draw(img)
draw.text((center_x, 540), "主标题", font=FONT, fill="white")
img.save(f"frame_{fnum:05d}.png")
# 编码:PNG 序列 + 音频 → MP4
ffmpeg -framerate 30 -i frame_%05d.png -i audio.mp3 \
-c:v libx264 -preset fast -crf 23 -c:a aac -b:a 128k \
-shortest segment.mp4
PIL 方案优势:阴影、进度条、渐变、多行文字等完全可控,不依赖 ffmpeg 编译选项。
Step 4: 拼接 + 压缩
# Concat
for f in segment_*.mp4; do echo "file '$f'" >> concat.txt; done
ffmpeg -y -f concat -safe 0 -i concat.txt -c copy final.mp4
# 压缩(Telegram < 50MB)
ffmpeg -i final.mp4 -c:v libx264 -preset fast -crf 28 -c:a aac -b:a 64k compressed.mp4
分镜模板
01 🪝 钩子:痛点数字("10件事只记3件 → 装上记8件")
02 🔍 问题:为什么难("三种方案,三种硬伤")
03 🏗️ 方案:核心架构("四层渐进记忆 L0→L1→L2→L3")
04 🔗 亮点1:追溯链("L3→L2→L1→L0 证据链不断裂")
05 📐 亮点2:压缩("Token -61%,完成率 +23%")
06 📊 数据:跑分("准确率 48→76%,召回 30→79%")
07 🚀 CTA:一行命令("开源地址搜 TencentDB-Agent-Memory")
Method C: HTML → Playwright Screenshot(设计感最强)
当需要 CSS 渐变背景、阴影、排版精致度远超 PIL 时使用。
✅ 推荐架构:Jinja2 模块化管线
不要写"一个脚本硬扛全流程"——CSS 和 Python 模板同居必定出 bug。用四层分离:
~/.hermes/tools/ai-qujing/
├── config.json # 场景数据(TTS 文本、配色、字号)——改场景不动代码
├── templates/slide.html # Jinja2 模板——CSS 零转义,天然隔离
├── tts.py # Edge TTS 语音合成
├── renderer.py # Jinja2 渲染 → Playwright 截图
├── encoder.py # ffmpeg 帧编码 + 多段合成
└── build.py # 编排器:读 config → TTS → 渲染 → 合片
用法:
cd ~/.hermes/tools/ai-qujing
python build.py # 读 config.json → output/config/
python build.py --config episode-03.json # 指定配置
python build.py --out /tmp/my-video # 指定输出目录
新文章只需改 config.json 的 scenes 数组,不动任何 Python 代码。
⚠️ 旧方案:Playwright Python API 直接调用(仅备查)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1080, "height": 1920})
page.goto(html_path.resolve().as_uri(), wait_until="networkidle")
page.screenshot(path="screenshot.png", full_page=False)
browser.close()
⚠️ 用 Python API,不要用 CLI:npx playwright screenshot 在本机有 parser 错误,Python playwright.sync_api 稳定。
⚠️ 本地 HTML 必须用 file://:html_path.resolve().as_uri() 生成 file:///path/to/file.html。
⚠️ 如果用 .format() 渲染模板,CSS 要双花括号(见 Pitfall #6)。建议直接改用 Jinja2,一劳永逸。
Pitfalls
- MiniMax
sk-cp-key 不能用于 TTS:MiniMax 有两种 key 格式。sk-cp-前缀的 key 在 TTS API 返回status_code: 1004 login fail。需要 JWT 格式 key(eyJ...)。建议用 Edge TTS 作为默认。 - ffmpeg drawtext 逗号问题:
-vf参数中 drawtext 之间用逗号分隔。如果文字本身含逗号,会被误解析。用:text='...'单引号包裹。 - 音频时长获取:
ffprobe -v quiet -show_entries format=duration -of csv=p=0 audio.mp3 - 视频卡死:
-shortest参数确保视频随音频结束,防止无声黑屏。 - Telegram 50MB 限制:大于 50MB 需压缩,
-crf 28通常能压到 10-30MB。 - 🐢 PIL 逐帧渲染极慢 → 用 ffmpeg 单帧循环(fast mode):8 场景约 5000 帧,每帧 PIL 操作 0.3 秒,全链路 15-25 分钟。默认已改为 fast mode——每场景只渲染 1 帧关键帧,ffmpeg
-loop 1+-tune stillimage循环编码,全链路 ~3 分钟(50x 提速)。encoder.py 新增frame_to_mp4()方法:ffmpeg -loop 1 -i frame.png -i audio.mp3 -tune stillimage -shortest out.mp4。画面效果基本一致(无逐帧动效,但对于文字卡+照片背景的场景感知不到差异)。如需逐帧动效(粒子/渐变漂移),改回frames_to_mp4()但预期 15-25 分钟。 - ❌ Python
.format()+ CSS = 花括号地狱(应避免,用 Jinja2 替代):当 HTML 模板用.format(**kwargs)渲染时,CSS 里的{...}会被当作占位符。三个铁律:- 所有 CSS 规则块用
{{...}}:body {{ width:100%; }}→ 渲染后body { width:100%; } - CSS 内嵌的真实占位符保持单花括号:
{{ margin-top:{big_top}px; }}→ 外层{{}}转义,内层{big_top}正常替换 - 表达式不能写在花括号里:
{W-120}→ KeyError。必须W120 = W - 120预计算后传入 - 错误表现:
KeyError: ' margin'(* { margin:0 }没转义)、KeyError: '\\n width'(body {\n width:...没转义)、KeyError: 'W-120'(表达式不被支持)
- 所有 CSS 规则块用