The most common mistake when automating Shorts is cramming everything into one script. If one step fails you start over, and there is nowhere for a human to review. In practice, splitting the work into stages and saving each stage’s output as files is far more reliable.
1. The overall structure
| Stage | Input → output | Example tools |
|---|---|---|
| 1. Topic and script | Topic list → script.json (title, lines, hashtags) | An LLM API (Claude, GPT, etc.) |
| 2. Voice | Script → narration.mp3 | A TTS API or your own recording |
| 3. Subtitles | Audio → subs.srt | faster-whisper for timings |
| 4. Visuals | Scene notes per line → images/*.png | Your own footage, stock, an image API |
| 5. Compose | Images + audio + subtitles → short.mp4 (1080×1920) | ffmpeg |
| 6. Review | A human marks it approved | Move to a folder or drop a flag file |
| 7. Upload | short.mp4 + metadata → scheduled publish | YouTube Data API v3 |
Give each job its own folder and record the current stage in job.json. You can then rerun only the failed stage and spread work across several machines.
jobs/
2026-11-09_grub-rescue/
job.json # {"stage": "compose", "topic": "...", "error": null}
script.json
narration.mp3
subs.srt
images/01.png 02.png 03.png
short.mp4
2. The compose stage (ffmpeg)
This is the fiddliest stage. The function below splits the narration length across your images, fills a vertical frame, adds a slow zoom, burns in the subtitles and writes an MP4. It needs ffmpeg installed; use a subtitle font name that is installed on the system.
"""Images + narration audio + SRT subtitles -> 1080x1920 Shorts MP4 (requires ffmpeg)."""
import subprocess, json
from pathlib import Path
def duration(path: str) -> float:
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", path],
capture_output=True, text=True, check=True).stdout
return float(json.loads(out)["format"]["duration"])
def compose(images: list[str], audio: str, srt: str, out: str, fps: int = 30):
total = duration(audio)
per = total / len(images)
frames = int(per * fps)
inputs, filters = [], []
for i, img in enumerate(images):
inputs += ["-loop", "1", "-t", f"{per:.3f}", "-i", img]
# fill the vertical frame + slow zoom (Ken Burns effect)
filters.append(
f"[{i}:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
f"zoompan=z='min(zoom+0.0008,1.08)':d={frames}:s=1080x1920:fps={fps},setsar=1[v{i}]")
concat = "".join(f"[v{i}]" for i in range(len(images)))
style = "FontName=Pretendard,FontSize=14,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Alignment=2,MarginV=120"
filters.append(f"{concat}concat=n={len(images)}:v=1:a=0[base]")
filters.append(f"[base]subtitles='{srt}':force_style='{style}'[outv]")
cmd = ["ffmpeg", "-y", *inputs, "-i", audio, "-filter_complex", ";".join(filters),
"-map", "[outv]", "-map", f"{len(images)}:a", "-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", out]
subprocess.run(cmd, check=True)
if __name__ == "__main__":
imgs = sorted(str(p) for p in Path("images").glob("*.png"))
compose(imgs, "narration.mp3", "subs.srt", "short.mp4")
- Landscape images still fill the vertical frame thanks to
force_original_aspect_ratio=increasepluscrop. - Tune the zoom speed (0.0008) and maximum zoom (1.08) in
zoompanto taste. - On Windows, backslashes and the drive colon (
C:) in the subtitle path can break the subtitles filter. Running from the job folder with relative paths avoids that.
3. The script stage decides quality
Ask an LLM for “a Shorts script” and you get the same thing everyone else gets. Lock the format and feed it your own experience or material so the result is specific to your channel.
- Force JSON output: title, a two-second hook, 5–7 body lines each with a scene note, a closing line, hashtags
- 2–4 seconds per line, 30–50 seconds total
- Have it flag numbers and names that need fact-checking so you can verify them at review
4. Upload gotchas
videos.insertin the YouTube Data API v3 costs 1,600 quota units per call, and the default quota is 10,000 units a day, so expect about six uploads per day.- Videos uploaded from an API project that has not passed Google’s audit are locked to private. Public uploads require completing the API audit.
- Set
status.privacyStatustoprivateandstatus.publishAtto a time to schedule a release.
5. Policy: why a human still belongs in the loop
The YouTube Partner Program excludes mass-produced or repetitive content from monetization. Flooding a channel with template-swapped videos can get monetization removed for the whole channel. Use automation for the repetitive parts (rendering, subtitles, uploads) and keep human judgment and original material for topic choice, script review and your own footage. If you use realistic AI-generated people or scenes, tick the altered or synthetic content disclosure when uploading.
Summary
- Separate stages, one folder per job and a job.json for retries and distribution
- One ffmpeg call handles images, audio and subtitles
- About six API uploads per day; unaudited projects stay private
- Keep a review step to protect both quality and monetization