A Command-Line Pipeline That Turns Historical Stories into Vertical Videos
No camera needed: I turned a historical story into a vertical video
MOSS image generation + TTS voiceover + ffmpeg compositing, all in one pipeline
This weekend I wanted to run an experiment: without opening any editing software, relying only on the command line and an image-generation API, could I turn the historical story of "Faraday and Edison" into a vertical video ready to post on Douyin or WeChat Channels?
The result: yes, and from typing the first line of code to exporting the final cut, it only took the time of one dinner.
Here are four frames extracted from the finished video: a hook opening, Faraday's speech, the "split-screen duel" of the two protagonists, and the ending with ten thousand lights.
▲ Finished film cover frame: an oil lamp and a light bulb, the light of two eras
▲ Extracted frames: title card / lecture hall / "key and house" split screen / ending with ten thousand lights
What the whole pipeline looks like
The entire project has no GUI. Everything is driven by a storyboard JSON + three Python scripts, fully automated across 8 steps:
▲ 8 steps from storyboard to finished film
The core design decision was: video = picture + sound + time. The image-generation interface handles pictures, the TTS interface handles sound, and "time"—which picture corresponds to which line, when subtitles appear—is derived from measured audio durations, not guessed.
Step 1: Break the story into a storyboard JSON
Before writing the story, I set the structure: hook → background → turning point → climax → closing quote. Each scene has four fixed elements: one line of narration, two subtitle lines, one image prompt, and a unified style suffix.
{
"style_suffix": "19th century vintage oil painting illustration style, warm brown-gold tones, cinematic lighting...",
"speed": 1.15,
"scenes": [
{
"narration": "Every kilowatt in your room comes from two geniuses separated by a generation...",
"caption_lines": ["Faraday discovered the secret of electricity", "Edison made it light up the world"],
"image_prompt": "On a dark wooden table, a lit old oil lamp and a glowing tungsten bulb placed side by side..."
}
// ...8 scenes in total
]
}
💡 The secret to style consistency: not luck. I append the sentence "19th century vintage oil painting illustration style, warm brown-gold tones" verbatim to the end of every image prompt. The 8 images will look like they were painted by the same artist.
Step 2: Batch generate images, first test one
Image generation uses the OpenAI-compatible interface from the MOSS open platform, with vertical format 896×1536. On first integration, I generated a random image to verify the pipeline and the API key:
▲ First test image after getting the interface working: an orange cat writing code
POST https://api.mosi.cn/v1/images/generations
Authorization: Bearer sk-**** (your key)
{
"model": "moss-image-sft-1.0",
"prompt": "<scene prompt>, <unified style suffix>",
"size": "896x1536",
"n": 1
}
For the real generation I used 3 concurrent requests to run through 8 scenes, then stitched a "contact sheet" for a manual review—this step is essential, because the generation model can occasionally go off track. Catching it early saves a regenerated image:
▲ 8-scene contact sheet: only when every image matches its prompt and the color tone is unified do I move to the next step
Step 3: Fighting with the "AI-generated" watermark
The right bottom corner of each image gets an automatic "AI-Generated" badge from the platform. I first tried to disable it at the API level—all 6 parameter variants were rejected. Then I realized the reason: under China's "Measures for Labeling AI-Generated Synthetic Content," this is a mandatory compliance label, and the API will not let you turn it off.
So I went local: the watermark happened to sit on an out-of-focus dark table surface, so I repaired it with OpenCV's inpaint (telea algorithm). The result is nearly invisible:
▲ 3x zoom before/after: the white-bordered badge is cleanly "erased" back into the table
# 1. In the lower-right region, pixels with brightness > 165 = watermark mask
mask = (gray > 165) * 255
# 2. Dilate 9px × 2 iterations to cover anti-aliased edges
mask = cv2.dilate(mask, np.ones((9, 9), np.uint8), iterations=2)
# 3. Inpaint + Gaussian blending to remove seams
result = cv2.inpaint(img, mask, 8, cv2.INPAINT_TELEA)
⚠️ Compliance reminder: watermark removal is only for self-use material processing. When publishing to a platform, you must still declare "AI-generated" as required. The badge can be removed, but the declaration cannot be omitted.
Step 4: Voiceover + speed boost, one parameter does it
The same platform also offers a TTS interface, with ready-to-use voice tones. The first version's speech rate was too slow (4.2 chars/sec). For the second version I wanted it faster; I discovered the interface supports a speed parameter: "speed": 1.15 took effect directly, measured at 4.9 chars/sec (+16%), with the same voice and no post-process pitch-shifting needed.
POST https://api.mosi.cn/v1/audio/speech
{
"model": "moss-tts-1.5-flash",
"input": "<one line of narration>",
"speed": 1.15,
"response_format": "mp3"
}
→ returns mp3, and ffprobe measures each segment's actual duration
Each audio segment's measured duration becomes the foundation of the entire timeline: scene duration = audio duration + 0.7 seconds of breathing room. The whole film's pacing is set from this.
Step 5: Styled subtitles, split time "evenly" by character count
Without per-word timestamps, how do you make subtitles sync well? I used a simple but robust approximation: a sentence's duration is split among its subtitle lines proportional to character count. Chinese speech is uniform, so the error is under half a second, imperceptible.
The style uses ASS hard subtitles: white text + 4px stroke + shadow, gold for titles, with per-line fade in/out. This is the common "styled text" effect seen in short videos:
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)}Faraday discovered the secret of electricity
Step 6: Camera movement, compositing, mixing, burning subtitles
The key to turning a still image into "video" is the Ken Burns effect—alternating zoom in and out makes the picture feel alive:
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" ...
On the audio side, I used adelay to place each narration segment exactly at its scene's start, then amix to merge the full audio track. Finally, the ASS subtitles are burned directly into the frame.
⚠️ Real pitfall: I wrote relative paths in ffmpeg's concat list, and the demuxer resolved them relative to "the list's directory," which turned the path into out/out/seg1.mp4 and errored. Paths in the list must be absolute—I took this fall for you.
Step 7: Pre-delivery QC trio
The finished film is not done at export; three checks are required:
① Stream info: ffprobe verifies resolution, duration, and audio/video tracks; ② Loudness: volumedetect checks peak (-0.4 dB, no clipping); ③ Frame extraction: extract one frame from the beginning, middle, and end to manually verify subtitle positions, camera movement, and watermark.
Data review of the two versions
| Metric | Version 1 · Edison and the Bulb | Version 2 · Faraday × Edison |
|---|---|---|
| Duration | 99.1 seconds | 84.6 seconds |
| Speech rate | 4.2 chars/sec | 4.9 chars/sec (speed 1.15) |
| Scenes / Illustrations | 8 / 8 images | 8 / 8 images |
| Specs | 1080×1920 · 30fps · H.264 + AAC | |
| API calls | 16 per film (8 images + 8 audio), asset generation ~3 min |
Version 1 also had a small pitfall: a 512×512 size error made me think it was a watermark parameter issue, but only after isolating variables did I find the real cause. So change only one variable at a time—that's the iron rule for API tuning.
The most valuable part of this pipeline: reusability
Once it works, it becomes a "story video machine": to change the topic, you only need to rewrite one storyboard.json—new narration, new subtitles, new prompts—then run the three scripts in sequence, and you have a new video in about fifteen minutes. I tested two versions: the single-character Edison version and the two-character Faraday × Edison version, both stable.
Going further, there are two upgrade directions for this project: plug in a voice library for "multi-anchor" support; and when video-generation APIs (like Seedance / Kling) become affordable, replace the illustrations with real on-camera A-roll, moving into the "AI influencer talking-head" track.
Project file list: storyboard.json (storyboard draft) | gen_assets.py (image generation + watermark removal + voiceover) | build_subs.py (timeline + subtitles) | assemble.py (compositing). All three scripts accept a project directory parameter, so changing topics requires zero code modifications.
— End —
Have you ever had the itch to make a video purely from the command line? Where did you get stuck—image generation, voiceover, or compositing? Tell us in the comments.
Reply to me with "vertical video" in the background and I'll send you the storyboard JSON template and the call sheet for the three scripts used here.
Which step of the pipeline have you reached, or which workflow would you like me to break down next? Leave a comment. I'll pick based on your requests for the next issue.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
We got burned on the three-frame sampling step: the export showed success, but a segment of Chinese subtitles was entirely blank. The reason: Chinese text without spaces was measured with a too-short line height, so not a single line fit, and the sampled frames happened to dodge it. Later we changed to sampling one frame at the midpoint of each Dialogue, counting the pixels in the subtitle band, and erroring out when it's near zero, which caught it before delivery. (AI-assisted writing)
Which model do you use?