## 〇、前言:为什么要做这件事
家里娃 正值英语启蒙黄金期。市面上的英语启蒙 App 看了不少,要么收费贵,要么动画太快娃跟不上,要么发音不够生活化。
最后决定自己 DIY 一套:以"Mom 和 Kid 日常对话"为题材,每张卡片一个生活场景(起床、刷牙、吃饭、做作业等),配中英双语字幕 + 标准英语配音 + 动静态画面切换。
最终产出:**76 张 3:4 高清卡片**(12 个主题)+ **76 个 MP4 动画视频**(每段 20-30 秒)+ **1 份 76 页 PDF 电子绘本**。
整个项目断断续续用了 3 天左右(不全是动手,主要是迭代和踩坑),本文是完整复盘。
其实,还得是用minimax订阅+原生的APP(MiniMax Code),自然语言直接就接入多模态,方便的很,用我的链接订阅,还有优惠呀,你如果有视频、教程想制作也可以试试 呀。
---
## 一、整体架构:4 步流水线
```
[对话文本]
│
▼
[Matrix MCP] ──→ 76 张静态图片 (3:4 PNG, 2K)
│
▼
[TTS 语音合成] ──→ 373 段 mp3 (Mom/Kid 双角色)
│
▼
[PIL 烧字幕] ──→ 76 × 5~6 张带字幕图
│
▼
[FFmpeg 合成] ──→ 76 个 mp4 视频
│
▼
[ReportLab 排版] ──→ 1 份 PDF 绘本
```
四个核心工具:**Matrix(MCP 协议)**、**Batch TTS**、**Pillow (PIL)**、**FFmpeg**。最后 ReportLab 收尾出 PDF。
---
## 二、第一步:76 张图片生成
### 2.1 场景设计
12 个主题,每个 4-10 张卡片,总共 76 张:
| 主题 | 张数 | 示例标题 |
|---|---|---|
| 早晨起床 | 2 | Wake up, honey. / Time to get out of bed. |
| 晨间活动 | 4 | Stretch your body. / Open your eyes. |
| 洗漱穿衣 | 7 | Go wash your face. / Comb your hair. |
| 饭桌日常 | 10 | Sit properly. / Chew slowly. |
| 做作业 | 10 | Hold your pen correctly. / Spell the word. |
| 玩耍互动 | 8 | Let's play together. / Be quiet please. |
| 行为礼貌 | 8 | Say please and thank you. / Wait your turn. |
| 夸奖鼓励 | 6 | Well done! / That's amazing! |
| 情绪安抚 | 5 | Don't cry. / Take a deep breath. |
| 出门&回家 | 6 | Hold my hand tight. / We're home! |
| 睡前晚安 | 5 | Time for bed. / I love you. |
| 简单问答 | 5 | How was your day? / Are you tired? |
### 2.2 Prompt 模板
风格锁定为**绘本插画风**(3:4 比例,暖色调,每个图包含完整对话气泡):
```python
prompt = """
A warm cozy [场景], soft natural light. [人物动作描述].
Soft pastel colors, picture book illustration style,
hand-drawn watercolor feel.
At the top of the image, a clear title in hand-drawn style font: [标题].
Below the scene, bilingual dialogue in speech bubbles,
English on top and Chinese translation below each line,
with Mom and Kid labels.
Dialogue: [对话内容]
Clear readable text, picture book style. 3:4 vertical aspect ratio.
"""
```
调用方式(Matrix MCP 协议):
```bash
mavis mcp call matrix matrix_generate_image \
'{"requests": [{"prompt": "...", "aspect_ratio": "3:4", "resolution": "2K"}]}'
```
### 2.3 一些坑
- **敏感词触发**:prompt 里如果出现"swallow"(吞咽)、"spit"(吐)、"hurt"(伤)等词会被某些内容审核拦截,**改成同义词绕开**(比如 "drink the water"、"the pain feels uncomfortable")
- **批量失败重试**:每批 10 张生成,偶尔有 1-2 张失败(如"The result is unavailable"),重试一次就好
- **中文字符**:绘本插画风的 AI 通常会画图,但中文字经常写错(笔画缺失、字形奇怪),所以后面我们用 **PIL 后期烧字幕**,不用 AI 直接写中文字
---
## 三、第二步:373 段 TTS 配音
### 3.1 选声音
Matrix 的 TTS 内置 300+ 声音,调研后选两个:
- **Mom**:`English_Graceful_Lady`(温柔英文女声,标准美音)
- **Kid**:`cute_boy`(可爱男童声,比 cute_boy-jingpin 更活泼)
### 3.2 批量调用
```python
from minimax_mcp import batch_text_to_audio
requests = []
for card in CARDS:
for i, (role, text) in enumerate(card.dialogues, 1):
requests.append({
"text": text,
"output_file_path": f"aud_c{card.id}_{i:02d}_{role.lower()}.mp3",
"voice_id": "English_Graceful_Lady" if role == "Mom" else "cute_boy",
"speed": 0.95 if role == "Mom" else 0.9, # 妈妈略慢一点
})
batch_text_to_audio(requests=requests)
```
**76 张卡片 × 平均 5 段对话 = 373 次 TTS 调用**。用 `batch_text_to_audio` 一次最多 10 个请求,所以分了 38 个 batch 并发跑(每次工具调用 10 个)。
实测 373 段 TTS 全部成功,耗时约 15 分钟。
---
## 四、第三步:动态字幕设计(关键!)
### 4.1 设计目标
原图里已经有完整对话气泡,但**孩子跟读时需要"当前在读哪句"的高亮**。所以我加了一层动态字幕:
- **底部 60 像素黑条**(半透明 55%)
- **左侧标签**:Mom(粉色 #FFB6C1)/ Kid(蓝色 #87CEEB)
- **右侧文本**:当前句的英文(Arial, 28pt, 白色)
### 4.2 PIL 烧字幕脚本
```python
from PIL import Image, ImageDraw, ImageFont
FONT = "/System/Library/Fonts/Supplemental/Arial.ttf"
MOM_COLOR = (255, 182, 193)
KID_COLOR = (135, 206, 235)
WHITE = (255, 255, 255)
BG = (0, 0, 0)
def burn_subtitle(src_img_path, out_path, role, text):
# 加载并缩放原图,顶部 720x900 留 60 像素给字幕
img = Image.open(src_img_path).convert("RGB")
sw, sh = img.size
scale = 900 / sh
img = img.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
canvas = Image.new("RGB", (720, 960), BG)
canvas.paste(img, ((720 - img.size[0]) // 2, 0))
draw = ImageDraw.Draw(canvas)
# 半透明黑底
overlay = Image.new("RGBA", (720, 80), (0, 0, 0, 180))
canvas.paste(overlay, (0, 880), overlay)
# 标签
f_role = ImageFont.truetype(FONT, 30)
f_text = ImageFont.truetype(FONT, 26)
color = MOM_COLOR if role == "Mom" else KID_COLOR
draw.text((20, 893), f"{role}:", fill=color, font=f_role)
draw.text((110, 895), text, fill=WHITE, font=f_text)
canvas.save(out_path, "PNG")
```
### 4.3 为什么不用 ffmpeg drawtext?
试过 ffmpeg 的 drawtext 滤镜,结果发现:
- 13 个 drawtext 串在一起非常慢(串行处理)
- 转义字符在 shell 和 ffmpeg 解析层有冲突,调试到吐血
**PIL 一次烧 6 张图,再让 ffmpeg 只做最简单的"图 + 音频"拼接** —— 这个架构**最稳**。
---
## 五、第四步:FFmpeg 视频合成(踩坑重灾区)
### 5.1 单段视频
每段 = 单张字幕图 + 单段音频(音频 + 0.5s 静默):
```python
import subprocess
def make_segment_video(img_path, audio_path, out_path):
audio_dur = get_dur(audio_path) # ffprobe
subprocess.run([
"/usr/local/bin/ffmpeg", "-y", "-loglevel", "error",
"-loop", "1", "-i", img_path,
"-i", audio_path,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
"-tune", "stillimage",
"-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2",
"-pix_fmt", "yuv420p", "-t", str(audio_dur),
"-vf", "scale=720:960:force_original_aspect_ratio=decrease,"
"pad=720:960:(ow-iw)/2:(oh-ih)/2",
out_path,
])
```
### 5.2 拼接多段
最开始的版本(**有 bug**):
```python
# ❌ 这个写法会让所有小视频堆在 0s,最终只显示第一段
subprocess.run([
"ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", "list.txt",
"-c", "copy", # ← 罪魁祸首!
out_path,
])
```
### 5.3 🐛 坑:concat demuxer + `-c copy` 的诡异行为
**症状**:所有 76 张视频文件大小几乎一样(5 段的 ~1.93MB,4 段的 ~1.53MB),md5 也几乎一样。
**根因分析**:
ffmpeg 的 `concat demuxer` 在 `-c copy` 模式下**不重新生成 pts(presentation timestamp)**。6 段小视频的 pts 都从 0 开始,导致它们在时间轴上**全部叠加**在 0s 位置:
```
pts: 0s 1s 2s 3s 4s 5s ... 24s
┃ ┃ ┃ ┃ ┃ ┃
seg1: [████████████████████] ← 5s
seg2: [████████████████████] ← 5s (覆盖在 seg1 上)
seg3: [████████████████████] ← 5s (覆盖)
seg4: [████████████████████] ← 5s
seg5: [████████████████████] ← 5s
```
ffmpeg 看到 seg1 结束就停了(因为流长度对齐到 seg1),后面几段被丢弃。最终视频里只能看到**第一段**的画面,但时长显示是 5 段总长(这是 stream metadata 的 bug,时间戳乱了但时长字段还在)。
### 5.4 修复方案
**方案 A:去掉 `-c copy`,强制重新编码**
```python
subprocess.run([
"ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", "list.txt",
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
"-c:a", "aac", "-b:a", "192k",
out_path,
])
```
**方案 B:用 filter_complex(更稳)**
```python
subprocess.run([
"ffmpeg", "-y",
"-i", "seg1.mp4", "-i", "seg2.mp4", ..., "-i", "seg6.mp4",
"-filter_complex",
"[0:v][0:a][1:v][1:a][2:v][2:a][3:v][3:a][4:v][4:a][5:v][5:a]"
"concat=n=6:v=1:a=1[v][a]",
"-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-c:a", "aac",
out_path,
])
```
### 5.5 验证方法
光看文件大小不够(看起来对,**内容其实是错的**),必须三重验证:
```python
# 1. MD5 唯一性
import subprocess
md5s = set()
for f in videos:
md5 = subprocess.check_output(["md5", "-q", f]).decode().strip()
md5s.add(md5)
assert len(md5s) == len(videos), "视频内容重复!"
# 2. 时长合理性 (5 段对话应该 ~24s)
for f in videos:
dur = get_dur(f)
expected = sum(seg_durations) + 0.5 * (n_segs - 1)
assert abs(dur - expected) < 1.0, f"时长不对: {f} {dur}s"
# 3. 抽几段抽帧对比
subprocess.run(["ffmpeg", "-i", video1, "-vf", "select=eq(n\,0)", "-vframes", "1", "frame1.jpg"])
subprocess.run(["ffmpeg", "-i", video2, "-vf", "select=eq(n\,0)", "-vframes", "1", "frame2.jpg"])
# 视觉对比 frame1 != frame2
```
### 5.6 另一个隐藏坑:list.txt 路径
ffmpeg `concat demuxer` 的 `file 'xxx'` 相对路径是**相对于 ffmpeg 进程的 cwd**,不是相对于 list.txt 位置。
```python
# ❌ 错: list.txt 在 work_dir, file 写 basename, cwd=workspace
# ffmpeg 会在 workspace/ 下找 seg1.mp4,找不到
# ✅ 对: list.txt 和 seg 在同一目录, ffmpeg cwd=work_dir
list_file = f"{work_dir}/_concat_list.txt"
subprocess.run(["ffmpeg", ...], cwd=work_dir)
```
---
## 六、批量处理:75 张卡片流水线
完整脚本 `/tmp/build_all_videos.py`:
```python
import subprocess
from cards_data import CARDS # 75 张卡片数据
from PIL import Image, ImageDraw, ImageFont
def process_card(card):
cid, en, zh, img, dialogues = card
work_dir = f"work_c{cid}"
os.makedirs(work_dir, exist_ok=True)
# 1. PIL 烧 6 张字幕图
for i, (role, text) in enumerate(dialogues, 1):
burn_subtitle(f"images/{img}", f"{work_dir}/seg{i}.png", role, text)
# 2. 每段音频 + 0.5s 静默 → 单段 mp3
for i, aud in enumerate(seg_audios, 1):
if i < len(seg_audios):
cat(f"{aud} silence_{i}.mp3", f"{work_dir}/aud_{i}_full.mp3")
else:
cp(aud, f"{work_dir}/aud_{i}_full.mp3")
# 3. 每段小视频
for i in range(1, len(dialogues) + 1):
make_segment_video(f"{work_dir}/seg{i}.png",
f"{work_dir}/aud_{i}_full.mp3",
f"{work_dir}/seg{i}.mp4")
# 4. concat 多段 → 最终视频 (重新编码!)
concat_videos(seg_videos, f"card_{cid:02d}_{safe_name}.mp4", work_dir)
```
后台跑了 1.5 小时处理 75 张 × 6 段 = 450 段小视频 + 75 次 concat。
---
## 七、PDF 电子绘本(顺便做的)
用 **ReportLab + STSong-Light** 排版:
```python
from reportlab.pdfgen import canvas
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light')) # 自带中文字符集
def draw_card(c, num, en_title, zh_title, img_path, total):
# A4 居中放图
c.drawImage(img_path, img_x, img_y, width=img_w, height=img_h)
# 底部英文标题 + 中文标题
c.setFont("Helvetica-Bold", 16)
c.drawString((W - tw) / 2, 20 * mm, f'"{en_title}"')
c.setFont("STSong-Light", 14)
c.drawString((W - zw) / 2, 12 * mm, zh_title)
```
**坑:macOS 自带的 PingFang.ttc / STHeiti.ttc 是 TTC(TrueType Collection)格式,reportlab 不支持**。必须用 reportlab 内置的 `STSong-Light`(自带中文字符集 + 嵌入字体子集)。
76 张卡片 + 1 封面 + 12 主题分隔页 = 89 页 PDF,**25 MB**。
---
## 八、性能 & 产出数据
| 阶段 | 数量 | 耗时 | 输出 |
|---|---|---|---|
| 图片生成 | 76 张 2K | ~30 min | 2.8 GB PNG |
| TTS 配音 | 373 段 | ~15 min | 130 MB MP3 |
| PIL 烧字幕 | 450 张 | ~5 min | 200 MB PNG |
| 单段小视频 | 450 段 | ~30 min | 1.5 GB MP4 |
| Concat 完整视频 | 75 段 | ~10 min | 150 MB MP4(最终 76 张) |
| PDF 排版 | 89 页 | ~2 min | 25 MB PDF |
| **总计** | | **~1.5 h** | **~5 GB 中间产物** |
最终交付物:
- 76 张卡片 PNG(单张 ~3 MB)
- 76 个 MP4 视频(单张 ~2 MB)
- 1 份 PDF 绘本(25 MB)
- **4 个 zip 分卷打包**(按主题分,共 108 MB)
---
## 九、关键经验 & 复盘
### ✅ 做得对的地方
1. **风格锁定 + Mom/Kid 标签**:从第一张到最后一张,所有图片风格高度统一,孩子一眼就能认出"这就是我们的卡片"
2. **PIL 烧字幕代替 drawtext**:避免了 13 个 drawtext 滤镜的转义地狱
3. **静帧 + 短音频 + concat**:用最简单的视频结构(每段就是一张图 + 一段音),拼接逻辑清晰
4. **每段加 0.5s 停顿**:孩子跟读时需要反应时间,停顿是必须的
### ❌ 踩的坑
1. **`-c copy` + concat demuxer 不重新生成 pts**:76 张视频全部是相同内容(卡 1 反复出现)。**必须重新编码**
2. **list.txt 路径用 basename + 错 cwd**:ffmpeg 找不到文件,输出空内容。**list.txt 必须在 work_dir 下,cwd 也设成 work_dir**
3. **drawtext 转义字符**:`,` 在 ffmpeg filter 里是分隔符,shell 里要 `\,`,多层转义搞死人
4. **ar 44100 / ac 2 重采样失败**:mp3 时间戳错乱。**用 `-ar 44100 -ac 2` 编码 aac 没问题,但用 aresample 滤镜会乱**
5. **忘了 -t 限制单段时长**:`-loop 1` + `-shortest` 没生效(默认图像流帧率 25),单段视频变 15s。**必须显式 `-t 音频时长`**
6. **md5 验证漏了**:光看文件大小没发现 bug,因为编码参数相同大小就接近。**md5 + 时长 + 抽帧对比 三重验证才稳**
### 💡 改进建议
- **写更稳的 list.txt 路径处理**:用绝对路径,避免相对路径的坑
- **TTS 之前先做 TTS 配额检查**:matrix 的 TTS 有并发限制(单次最多 10 个),需要先规划
- **批量任务分批跑 + 后台执行**:单次跑 75 张太重,应该用 cron 或 background 任务
---
## 十、完整代码
项目分这几个文件:
- `/tmp/cards_data.py` —— 75 张卡片的所有数据(标题、对话、图片路径)
- `/tmp/burn_subtitle.py` —— PIL 烧字幕脚本
- `/tmp/build_all_videos.py` —— 单张卡片的完整流程(PIL + 音频 + 视频)
- `/tmp/fix_concat.py` —— 修复 pts 错误的脚本
- `/tmp/make_pdf.py` —— ReportLab 排版脚本
完整代码和示例数据可在 GitHub 找到:[TODO 补仓库链接]
---
## 十一、最终效果
<视频示例 1:Wake up, honey>
字幕会跟着对话切换,孩子可以看着字幕跟读 👀
---
## 十二、写在最后
这套工具链最大的价值不是技术多炫,而是**把 76 个完整、有教育价值的内容,用 1.5 小时搞定了**。如果手工做(找画师画图、找配音、剪辑视频),至少要 1-2 周。
AI 工具链真正的杠杆:**把原本"贵"的内容变得"便宜",让个人也能做出原本需要团队/资金才能做的产品**。
希望这篇复盘能帮到也想做儿童教育内容的同学。有问题欢迎评论区交流~
---
话说回来,minimax的订阅真的是够用呀。而且现在还有折扣,点击这篇稿子即可收获哦。