Build a YouTube Shorts pipeline with Python and an AI API

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

StageInput → outputExample tools
1. Topic and scriptTopic list → script.json (title, lines, hashtags)An LLM API (Claude, GPT, etc.)
2. VoiceScript → narration.mp3A TTS API or your own recording
3. SubtitlesAudio → subs.srtfaster-whisper for timings
4. VisualsScene notes per line → images/*.pngYour own footage, stock, an image API
5. ComposeImages + audio + subtitles → short.mp4 (1080×1920)ffmpeg
6. ReviewA human marks it approvedMove to a folder or drop a flag file
7. Uploadshort.mp4 + metadata → scheduled publishYouTube 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=increase plus crop.
  • Tune the zoom speed (0.0008) and maximum zoom (1.08) in zoompan to 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.insert in 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.privacyStatus to private and status.publishAt to 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

Leave a Comment