跪拜 Guibai
← Back to the summary

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.

Cover: oil lamp and light bulb▲ Finished film cover frame: an oil lamp and a light bulb, the light of two eras

Extracted frames from the film▲ 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:

Pipeline▲ 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:

API test image▲ 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:

Storyboard contact sheet▲ 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:

Before/after watermark removal▲ 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.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

szp2005

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?