不用摄像机,我把历史故事做成竖屏视频

3 阅读7分钟

不用摄像机,我把历史故事做成竖屏视频

MOSS 生图 + TTS 配音 + ffmpeg 合成,一条流水线跑通

周末我想做个实验:不打开任何剪辑软件,只靠命令行和一个生图 API,能不能把"法拉第与爱迪生"的历史故事,做成一条能直接发抖音、视频号的竖屏视频?

结果是:可以,而且从敲下第一行代码到成片导出,只花了一顿晚饭的时间。

先看成片里截出来的四帧:钩子开场、法拉第演讲、两位主角的"分屏对决"、万家灯火的收尾——

封面:油灯与灯泡▲ 成片封面帧:一盏油灯与一只灯泡,两个时代的光

成片抽帧▲ 成片抽帧:标题卡 / 演讲厅 / "钥匙与房子"分屏 / 结尾万家灯火

整条流水线长什么样

整个项目没有一个 GUI,全部由一个分镜 JSON + 三个 Python 脚本驱动,8 个环节全自动串联:

流水线▲ 从分镜稿到成片的 8 个环节

之所以这样设计,核心是想清楚了一件事:视频 = 画面 + 声音 + 时间。生图接口负责画面,TTS 接口负责声音,而"时间"——每句话对应哪个画面、字幕什么时候出现——由脚本用实测的音频时长反推出来,不靠手拍脑袋。

第一步:把故事拆成分镜 JSON

写故事前先定结构:钩子 → 背景 → 转折 → 高潮 → 金句收尾。每个场景固定四样东西:一句旁白、两行字幕、一条画图提示词、统一的画风后缀。

{   "style_suffix": "19世纪复古油画插画风格,暖棕金色调,电影感布光……",   "speed": 1.15,   "scenes": [     {       "narration": "你房间里的每一度电,都来自两个隔着一代人的天才……",       "caption_lines": ["法拉第发现了电的秘密", "爱迪生让它照亮了世界"],       "image_prompt": "深色木桌上,一盏点燃的老油灯与一盏发光的钨丝灯泡并排放置……"     }     // …共 8 个场景   ] }

     💡 画风统一的秘诀:不是碰运气,而是把"19世纪复古油画插画风格,暖棕金色调"这句话原样拼进每一条生图提示词的末尾。8 张图就会像同一个画师画的。   

第二步:批量生图,先出一张试水

生图走的是模思(MOSS)开放平台的 OpenAI 兼容接口,竖屏用 896×1536。第一次接入时,我先随便出了张图验证通路和密钥:

API 测试图▲ 第一次调通接口的测试图:一只写代码的橘猫

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 倍的前后对比:白框徽标被干净地"抹"回桌面

# 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%),音色不变、不用后期变速。

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 描边 + 投影,标题用金色,逐句淡入淡出。这就是短视频里常见的"花字"效果:

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 运镜——推近、拉远交替,画面就活了:

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 模板和三个脚本的调用清单整理好发你。

这条流水线你走到哪一步了,或者你还想看我拆哪条工作流,评论区留言告诉我。 下一期我照着留言挑着写。