Short product videos sell on TikTok Shop, Shopee Video, Instagram Reels and YouTube Shorts, but editing one video per product by hand does not scale to a catalogue. This guide turns a folder containing product photos and a short narration script into a finished vertical 9:16 video with an AI voiceover, burned-in subtitles and optional background music, using a small Python script and ffmpeg on your server, and then produces videos for hundreds of products in one batch.
This guide builds on AI voiceover with Kokoro: set up /opt/tts and tts.py as described there first. Then install fonts for Chinese and Japanese subtitles:
apt install -y ffmpeg fonts-noto-cjk
fc-list | grep -i "Noto Sans CJK" | head -3
/data/videos/LS-01/
├── 01.jpg # photos, shown in name order
├── 02.jpg
├── 03.jpg
├── script.txt # narration, one subtitle line per line
└── music.mp3 # optional background music
cat > /opt/tts/makevideo.py <<'EOF2'
import os, subprocess, sys
import soundfile as sf
W, H, FPS = 1080, 1920, 30
FONT = os.environ.get("SUB_FONT", "Noto Sans CJK SC")
os.chdir(sys.argv[1])
images = sorted(f for f in os.listdir(".") if f.lower().endswith((".jpg", ".jpeg", ".png", ".webp")))
if not images:
sys.exit("No photos found in " + os.getcwd())
per = sf.info("script.wav").duration / len(images) # each photo stays on screen for an equal share
def ffmpeg(*args):
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
with open("clips.txt", "w") as lst:
for i, img in enumerate(images):
clip = f"clip{i:03d}.mp4" # photo centred on a blurred copy of itself, 9:16
ffmpeg("-loop", "1", "-t", f"{per:.3f}", "-i", img, "-filter_complex",
f"[0:v]split[a][b];[a]scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},boxblur=20[bg];"
f"[b]scale={W}:{H}:force_original_aspect_ratio=decrease[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2,format=yuv420p",
"-r", str(FPS), "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", clip)
lst.write(f"file '{clip}'\n")
style = f"FontName={FONT},FontSize=11,Bold=1,Outline=2,Shadow=0,Alignment=2,MarginV=50"
inputs = ["-f", "concat", "-safe", "0", "-i", "clips.txt", "-i", "script.wav"]
if os.path.exists("music.mp3"): # optional background music, quietly under the voice
inputs += ["-stream_loop", "-1", "-i", "music.mp3"]
audio = "[2:a]volume=0.15[m];[1:a][m]amix=inputs=2:duration=first,loudnorm=I=-14:TP=-1.5[a]"
else:
audio = "[1:a]loudnorm=I=-14:TP=-1.5[a]"
ffmpeg(*inputs, "-filter_complex", f"[0:v]subtitles=script.srt:force_style='{style}'[v];{audio}",
"-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", "-shortest", "-movflags", "+faststart", "video.mp4")
for f in ["clips.txt"] + [f"clip{i:03d}.mp4" for i in range(len(images))]:
os.remove(f)
print("done:", os.path.abspath("video.mp4"))
EOF2
Each photo stays on screen for an equal share of the voiceover. It is centred on a blurred, full-screen copy of itself, so landscape and portrait photos both fill the vertical frame. The script then joins the clips, adds the voice and, if present, the music at low volume, burns in the subtitles, normalises loudness to about -14 LUFS and writes video.mp4.
cd /data/videos/LS-01
TTS_LANG=z TTS_VOICE=zf_xiaobei /opt/tts/bin/python /opt/tts/tts.py script.txt
/opt/tts/bin/python /opt/tts/makevideo.py /data/videos/LS-01
The result is a 1080x1920, 30 fps H.264 video with AAC audio, the format all major short-video platforms accept. Subtitles sit above the bottom of the frame, clear of most platforms' buttons. For Japanese subtitles, run the second command with SUB_FONT="Noto Sans CJK JP". To change the subtitle size or position, adjust FontSize and MarginV in the style line of the script.
for d in /data/videos/*/; do
[ -f "$d/video.mp4" ] && continue # skip products that are already done
TTS_LANG=z TTS_VOICE=zf_xiaobei /opt/tts/bin/python /opt/tts/tts.py "$d/script.txt" &&
/opt/tts/bin/python /opt/tts/makevideo.py "$d" || echo "FAILED: $d"
done
Run long batches with nohup or inside screen so they continue after you log out. In our test, a 16-second video took about five seconds to render; most of the time is video encoding, so a multi-core CPU helps.
volume=0.15 in the script if needed.Yes, but the clips need to be cut to length and scaled the same way, which needs a modified script. A simpler option is to generate the voice and SRT with the voiceover guide and combine them with your clips in a video editor.
Translate script.txt, then run both commands with a voice for that language. For videos that already exist, see dubbing videos into another language with AI.
The font is missing. Install fonts-noto-cjk as in Step 1 and check that fc-list shows "Noto Sans CJK SC".
Still stuck after following these steps? Open a support ticket and the IMIDC 24/7 technical team will help. Include the server IP, OS version, the folder layout and the full error output so we can pinpoint the issue faster.