如何用 Matrix 画图 + TTS 配音 + PIL 烧字幕 + FFmpeg 合成,给娃做一套英语启蒙动画绘本
## 〇、前言为什么要做这件事家里娃 正值英语启蒙黄金期。市面上的英语启蒙 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 绘本四个核心工具**MatrixMCP 协议**、**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 | Lets play together. / Be quiet please. || 行为礼貌 | 8 | Say please and thank you. / Wait your turn. || 夸奖鼓励 | 6 | Well done! / Thats amazing! || 情绪安抚 | 5 | Dont cry. / Take a deep breath. || 出门回家 | 6 | Hold my hand tight. / Were home! || 睡前晚安 | 5 | Time for bed. / I love you. || 简单问答 | 5 | How was your day? / Are you tired? |### 2.2 Prompt 模板风格锁定为**绘本插画风**3:4 比例暖色调每个图包含完整对话气泡pythonprompt 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 协议bashmavis 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 批量调用pythonfrom minimax_mcp import batch_text_to_audiorequests []for card in CARDS:for i, (role, text) in enumerate(card.dialogues, 1):requests.append({text: text,output_file_path: faud_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(requestsrequests)**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 烧字幕脚本pythonfrom PIL import Image, ImageDraw, ImageFontFONT /System/Library/Fonts/Supplemental/Arial.ttfMOM_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.sizescale 900 / shimg 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_COLORdraw.text((20, 893), f{role}:, fillcolor, fontf_role)draw.text((110, 895), text, fillWHITE, fontf_text)canvas.save(out_path, PNG)### 4.3 为什么不用 ffmpeg drawtext试过 ffmpeg 的 drawtext 滤镜结果发现- 13 个 drawtext 串在一起非常慢串行处理- 转义字符在 shell 和 ffmpeg 解析层有冲突调试到吐血**PIL 一次烧 6 张图再让 ffmpeg 只做最简单的图 音频拼接** —— 这个架构**最稳**。---## 五、第四步FFmpeg 视频合成踩坑重灾区### 5.1 单段视频每段 单张字幕图 单段音频音频 0.5s 静默pythonimport subprocessdef make_segment_video(img_path, audio_path, out_path):audio_dur get_dur(audio_path) # ffprobesubprocess.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, scale720:960:force_original_aspect_ratiodecrease,pad720: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.93MB4 段的 ~1.53MBmd5 也几乎一样。**根因分析**ffmpeg 的 concat demuxer 在 -c copy 模式下**不重新生成 ptspresentation timestamp**。6 段小视频的 pts 都从 0 开始导致它们在时间轴上**全部叠加**在 0s 位置pts: 0s 1s 2s 3s 4s 5s ... 24s┃ ┃ ┃ ┃ ┃ ┃seg1: [████████████████████] ← 5sseg2: [████████████████████] ← 5s (覆盖在 seg1 上)seg3: [████████████████████] ← 5s (覆盖)seg4: [████████████████████] ← 5sseg5: [████████████████████] ← 5sffmpeg 看到 seg1 结束就停了因为流长度对齐到 seg1后面几段被丢弃。最终视频里只能看到**第一段**的画面但时长显示是 5 段总长这是 stream metadata 的 bug时间戳乱了但时长字段还在。### 5.4 修复方案**方案 A去掉 -c copy强制重新编码**pythonsubprocess.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更稳**pythonsubprocess.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]concatn6:v1:a1[v][a],-map, [v], -map, [a],-c:v, libx264, -c:a, aac,out_path,])### 5.5 验证方法光看文件大小不够看起来对**内容其实是错的**必须三重验证python# 1. MD5 唯一性import subprocessmd5s 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, selecteq(n\,0), -vframes, 1, frame1.jpg])subprocess.run([ffmpeg, -i, video2, -vf, selecteq(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, cwdworkspace# ffmpeg 会在 workspace/ 下找 seg1.mp4,找不到# ✅ 对: list.txt 和 seg 在同一目录, ffmpeg cwdwork_dirlist_file f{work_dir}/_concat_list.txtsubprocess.run([ffmpeg, ...], cwdwork_dir)---## 六、批量处理75 张卡片流水线完整脚本 /tmp/build_all_videos.pypythonimport subprocessfrom cards_data import CARDS # 75 张卡片数据from PIL import Image, ImageDraw, ImageFontdef process_card(card):cid, en, zh, img, dialogues cardwork_dir fwork_c{cid}os.makedirs(work_dir, exist_okTrue)# 1. PIL 烧 6 张字幕图for i, (role, text) in enumerate(dialogues, 1):burn_subtitle(fimages/{img}, f{work_dir}/seg{i}.png, role, text)# 2. 每段音频 0.5s 静默 → 单段 mp3for 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, fcard_{cid:02d}_{safe_name}.mp4, work_dir)后台跑了 1.5 小时处理 75 张 × 6 段 450 段小视频 75 次 concat。---## 七、PDF 电子绘本顺便做的用 **ReportLab STSong-Light** 排版pythonfrom reportlab.pdfgen import canvasfrom reportlab.pdfbase.cidfonts import UnicodeCIDFontpdfmetrics.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, widthimg_w, heightimg_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 是 TTCTrueType 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 补仓库链接]---## 十一、最终效果视频示例 1Wake up, honey字幕会跟着对话切换孩子可以看着字幕跟读 ---## 十二、写在最后这套工具链最大的价值不是技术多炫而是**把 76 个完整、有教育价值的内容用 1.5 小时搞定了**。如果手工做找画师画图、找配音、剪辑视频至少要 1-2 周。AI 工具链真正的杠杆**把原本贵的内容变得便宜让个人也能做出原本需要团队/资金才能做的产品**。希望这篇复盘能帮到也想做儿童教育内容的同学。有问题欢迎评论区交流---话说回来minimax的订阅真的是够用呀。而且现在还有折扣点击这篇稿子即可收获哦。