不用摄像机,我把历史故事做成竖屏视频
MOSS 生图 + TTS 配音 + ffmpeg 合成,一条流水线跑通
周末我想做个实验:不打开任何剪辑软件,只靠命令行和一个生图 API,能不能把"法拉第与爱迪生"的历史故事,做成一条能直接发抖音、视频号的竖屏视频?
结果是:可以,而且从敲下第一行代码到成片导出,只花了一顿晚饭的时间。
先看成片里截出来的四帧:钩子开场、法拉第演讲、两位主角的"分屏对决"、万家灯火的收尾------
▲ 成片封面帧:一盏油灯与一只灯泡,两个时代的光
▲ 成片抽帧:标题卡 / 演讲厅 / "钥匙与房子"分屏 / 结尾万家灯火
整条流水线长什么样
整个项目没有一个 GUI,全部由一个分镜 JSON + 三个 Python 脚本驱动,8 个环节全自动串联:
▲ 从分镜稿到成片的 8 个环节
之所以这样设计,核心是想清楚了一件事:视频 = 画面 + 声音 + 时间 。生图接口负责画面,TTS 接口负责声音,而"时间"------每句话对应哪个画面、字幕什么时候出现------由脚本用实测的音频时长反推出来,不靠手拍脑袋。
第一步:把故事拆成分镜 JSON
写故事前先定结构:钩子 → 背景 → 转折 → 高潮 → 金句收尾。每个场景固定四样东西:一句旁白、两行字幕、一条画图提示词、统一的画风后缀。
bash
{ "style_suffix": "19世纪复古油画插画风格,暖棕金色调,电影感布光......", "speed": 1.15, "scenes": [ { "narration": "你房间里的每一度电,都来自两个隔着一代人的天才......", "caption_lines": ["法拉第发现了电的秘密", "爱迪生让它照亮了世界"], "image_prompt": "深色木桌上,一盏点燃的老油灯与一盏发光的钨丝灯泡并排放置......" } // ...共 8 个场景 ] }
💡 画风统一的秘诀:不是碰运气,而是把"19世纪复古油画插画风格,暖棕金色调"这句话原样拼进每一条生图提示词的末尾。8 张图就会像同一个画师画的。
第二步:批量生图,先出一张试水
生图走的是模思(MOSS)开放平台的 OpenAI 兼容接口,竖屏用 896×1536。第一次接入时,我先随便出了张图验证通路和密钥:
▲ 第一次调通接口的测试图:一只写代码的橘猫
bash
POST https://api.mosi.cn/v1/images/generations Authorization: Bearer sk-****(你的密钥) { "model": "moss-image-sft-1.0", "prompt": "<场景提示词>,<统一画风后缀>", "size": "896x1536", "n": 1 }
正式生成时用 3 路并发跑完 8 个场景,再拼一张"联系表"人工过目一遍------这一步不能省,生成模型偶尔会跑偏,早发现早重生成:
▲ 8 个场景的联系表:每张都对题、色调统一,才进入下一环节
第三步:和"AI生成"水印斗智斗勇
生成的图右下角会被平台自动加盖"AI生成"徽标。我先试了接口层关闭------6 种参数写法全部被拒绝。后来想明白了:按国内《人工智能生成合成内容标识办法》,这是平台强制加的合规标识,接口不会给你关。
所以改走本地路线:水印恰好压在失焦的暗色桌面上,用 OpenCV 的 inpaint(telea 算法)修复,效果几乎是零痕迹:
▲ 放大 3 倍的前后对比:白框徽标被干净地"抹"回桌面
bash
# 1. 右下角区域内,亮度 > 165 的像素 = 水印掩膜 mask = (gray > 165) * 255 # 2. 膨胀 9px×2 轮,盖住抗锯齿边缘 mask = cv2.dilate(mask, np.ones((9, 9), np.uint8), iterations=2) # 3. inpaint 修复 + 高斯融合,消掉接缝 result = cv2.inpaint(img, mask, 8, cv2.INPAINT_TELEA)
⚠️ 合规提醒:去水印仅用于自用素材处理。发布到平台时,仍应按规则声明"AI 生成"------标识可以抹,声明不能省。
第四步:配音 + 提速,一个参数搞定
同一个平台还有 TTS 接口,音色开箱即用。第一版视频语速偏慢(4.2 字/秒),第二版想加快,结果发现接口支持 speed 参数:"speed": 1.15 直接生效,实测语速提到 4.9 字/秒(+16%),音色不变、不用后期变速。
bash
POST https://api.mosi.cn/v1/audio/speech { "model": "moss-tts-1.5-flash", "input": "<一句旁白>", "speed": 1.15, "response_format": "mp3" } → 返回 mp3,ffprobe 逐段实测时长
每段音频的实测时长,就是后面整个时间线的地基:场景时长 = 音频时长 + 0.7 秒呼吸空隙,全片节奏由此确定。
第五步:花字字幕,按字数"均分"时间
没有逐词时间戳,怎么让字幕跟读得准?用了一个简单却稳的近似:一句话的时长按字数比例分给它的每行字幕。中文语速均匀,误差不到半秒,肉眼无感。
样式走 ASS 硬字幕:白字 + 4px 描边 + 投影,标题用金色,逐句淡入淡出。这就是短视频里常见的"花字"效果:
bash
Format: Name, Fontname, Fontsize, PrimaryColour, ... Style: Caption,Hiragino Sans GB,62,&H00FFFFFF,...,1,4,2,2,70,70,200,1 Dialogue: 0,0:00:00.35,0:00:06.66,Caption,,0,0,0,,{\fad(160,160)}法拉第发现了电的秘密
第六步:运镜、拼接、混音、烧字幕
静图变成"视频"的关键是 Ken Burns 运镜------推近、拉远交替,画面就活了:
bash
ffmpeg -loop 1 -i scene1.png -vf "\ scale=1400:2400,\ zoompan=z='min(zoom+0.00022,1.10)':\ x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\ d=303:s=1080x1920:fps=30" ...
音频侧用 adelay 把每段旁白精确放到自己场景的起点,amix 合成整条音轨;最后一步把 ASS 字幕直接烧进画面。
⚠️ 实录踩坑 :ffmpeg 的 concat 清单里写了相对路径,结果 demuxer 按"清单所在目录"解析,路径变成 out/out/seg1.mp4 直接报错。清单里的路径必须写绝对路径------这个坑我替你踩过了。
第七步:交付前的 QC 三件套
成片不是导出就算完,三道检查不能少:
① 流信息 :ffprobe 核对分辨率、时长、音视频轨;② 响度 :volumedetect 看峰值(-0.4 dB,未削波);③ 抽帧:开头/中段/结尾各抽一帧,人眼核对字幕位置、运镜、水印。
两版视频的数据复盘
| 指标 | 第一版 · 爱迪生与灯泡 | 第二版 · 法拉第×爱迪生 |
|---|---|---|
| 时长 | 99.1 秒 | 84.6 秒 |
| 语速 | 4.2 字/秒 | 4.9 字/秒(speed 1.15) |
| 场景 / 插画 | 8 / 8 张 | 8 / 8 张 |
| 规格 | 1080×1920 · 30fps · H.264 + AAC | |
| API 调用 | 每片 16 次(8 图 + 8 音),素材生成约 3 分钟 |
第一版里也踩过一个小坑:512×512 尺寸报错让我误以为是水印参数的问题,隔离变量后才定位到真正原因。所以每次只改一个变量,是调 API 的铁律。
这套流程最值钱的地方:可复用
跑通之后,它就变成了一台"故事视频机器":换一个主题,只需要重写一个 storyboard.json------新的旁白、新的字幕、新的提示词,然后依次跑三个脚本,十几分钟后就是一条新片。我实测了两条:爱迪生单人物版、法拉第×爱迪生双人物版,稳定性都不错。
再往后,这套工程还有两个升级方向:接入语音音色库做"多主播";等有视频生成模型(Seedance / Kling 这类)的 API 额度,把插画换成真人出镜的 A-roll,就真正进入"AI 达人口播"的赛道了。
工程清单:storyboard.json(分镜稿)|gen_assets.py(生图+去水印+配音)|build_subs.py(时间线+字幕)|assemble.py(合成)。三个脚本都支持传入项目目录参数,换主题零改动复用。
--- 完 ---
你动过"纯命令行做条视频"的念头吗?卡点一般在生图、配音还是合成那一环,评论区说说看。
后台回我「竖屏视频」四个字,我把这篇用到的分镜 JSON 模板和三个脚本的调用清单整理好发你。
这条流水线你走到哪一步了,或者你还想看我拆哪条工作流,评论区留言告诉我。 下一期我照着留言挑着写。