To create a video from images with FFmpeg, fit every image into one fixed canvas before it reaches the encoder: scale with force_original_aspect_ratio=decrease and force_divisible_by=2, then pad (or a blurred fill) to exactly 1920×1080, convert to limited-range yuv420p, hold each still with -loop 1 plus trim, and join the stills with xfade. Then check the result with ffprobe: frame count, size, SAR, pixel format and a duration of n × hold − (n − 1) × fade.
The usual one-liner skips the fitting step. With a folder of mixed photos it either refuses to start or, worse, exits 0 and quietly stretches every portrait photo. Below is what that looks like on three real stills, a ~90-line Python wrapper that does it properly, and 12 pytest checks that catch the failures.
The test images
Three stills from the public examples on Voor's photo-to-video page, chosen because their shapes disagree:
| File | Size | Problem it triggers |
|---|---|---|
landscape-input.jpg |
1280×853 | odd height |
portrait-input.jpg |
1080×1920 | 9:16 into a 16:9 canvas |
product-input.jpg |
1080×1920 | 9:16, label text that shows distortion |
All three are baseline JPEGs, which means full-range yuvj420p once decoded. That matters later.
What the one-liner actually does
Straight to libx264, no scaling:
ffmpeg -framerate 1/3 -i %02d.jpg -r 30 -c:v libx264 -pix_fmt yuv420p naive.mp4
# [libx264] height not divisible by 2 (1280x853)
4:2:0 chroma needs even width and height. So people add scale=-2:1080:
ffmpeg -framerate 1/3 -i %02d.jpg -vf scale=-2:1080 -r 30 \
-c:v libx264 -pix_fmt yuv420p naive.mp4 # exit code 0
It succeeds, and ffprobe on the output says:
| Check | naive.mp4 |
|---|---|
| frame size | 1620×1080 for all 270 frames |
| SAR | 2560:2559 |
| pix_fmt / range | yuvj420p / pc |
| duration | 9.0 s, hard cuts |
scale=-2:1080 produced 1620×1080 for the landscape and 608×1080 for the two portraits. An encoder cannot change size mid-stream, so FFmpeg's auto-inserted scaler forced every later frame back to the first frame's 1620×1080: the portrait and the product tube are stretched 2.667× wide. No warning is printed. The odd SAR and the full-range yuvj420p tag come along for free, and some players and upload pipelines misread both.
A filter graph that fits every image
The fix is to give each input its own chain and make all chains end in the same size, rate, range and format, so the joins never have to guess:
-
Fit, don't force.
scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2keeps the shape and guarantees even sides. -
Fill the rest.
padgives black bars. Theblurmode puts a cover-cropped,boxblurred copy of the same image behind it, which is what most vertical-photo slideshows use. -
Normalize.
setsar=1,out_range=tv,format=yuv420p,fps=30. -
Bound each still.
-loop 1is infinite;trim=duration=3plussetpts=PTS-STARTPTSmakes it a 3-second clip. -
Join with
xfade. Itsoffsetis on the output timeline, so the k-th fade starts atk × (hold − fade), notk × hold.
"""Create a video from still images with FFmpeg.
build_slideshow() turns any mix of image sizes into one H.264 MP4:
- every image is fitted into the output canvas without stretching
(scale with force_original_aspect_ratio, then pad or a blurred fill),
- every intermediate size is forced to even numbers (yuv420p needs that),
- each still is held for `hold` seconds and joined with xfade crossfades,
- JPEG full-range input is converted to limited (TV) range yuv420p,
- an optional silent AAC track keeps players and upload checks happy.
expected_duration() and xfade_offsets() are the arithmetic the filter graph
depends on; the tests compare them with what ffprobe measures.
"""
from __future__ import annotations
import json
import os
import subprocess
from fractions import Fraction
from pathlib import Path
FFMPEG = os.environ.get("FFMPEG", "ffmpeg")
FFPROBE = os.environ.get("FFPROBE", "ffprobe")
def expected_duration(n: int, hold: float, fade: float) -> float:
"""n stills of `hold` seconds joined by n-1 crossfades of `fade` seconds."""
if n < 1:
raise ValueError("need at least one image")
if n > 1 and not 0 < fade < hold:
raise ValueError("fade must be shorter than hold")
return n * hold - (n - 1) * fade
def xfade_offsets(n: int, hold: float, fade: float) -> list[float]:
"""Offset of each xfade, measured on the already-joined output timeline."""
return [round(k * (hold - fade), 6) for k in range(1, n)]
def fit_chain(index: int, width: int, height: int, fps: int, hold: float, fill: str) -> str:
"""Filter chain for one looped still -> [v{index}] at exactly width x height."""
common = f"fps={fps},trim=duration={hold},setpts=PTS-STARTPTS,format=yuv420p"
fg = (f"scale={width}:{height}:force_original_aspect_ratio=decrease:"
f"force_divisible_by=2:out_range=tv,setsar=1")
if fill == "pad":
return (f"[{index}:v]{fg},pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"{common}[v{index}]")
if fill == "blur":
return (f"[{index}:v]split=2[bg{index}][fg{index}];"
f"[bg{index}]scale={width}:{height}:force_original_aspect_ratio=increase,"
f"crop={width}:{height},scale=out_range=tv,boxblur=20:2,setsar=1[bgb{index}];"
f"[fg{index}]{fg}[fgs{index}];"
f"[bgb{index}][fgs{index}]overlay=(W-w)/2:(H-h)/2,{common}[v{index}]")
raise ValueError(f"unknown fill {fill!r}")
def build_command(images, out, *, width=1920, height=1080, fps=30, hold=3.0,
fade=0.5, fill="blur", audio=True) -> list[str]:
images = [str(p) for p in images]
n = len(images)
total = expected_duration(n, hold, fade)
if width % 2 or height % 2:
raise ValueError("yuv420p output needs even width and height")
cmd = [FFMPEG, "-v", "error", "-y"]
for img in images:
# -loop 1 makes an endless stream; trim inside the graph bounds it.
cmd += ["-loop", "1", "-framerate", str(fps), "-t", str(hold), "-i", img]
if audio:
cmd += ["-f", "lavfi", "-t", str(total), "-i", "anullsrc=r=48000:cl=stereo"]
chains = [fit_chain(i, width, height, fps, hold, fill) for i in range(n)]
last = "v0"
for k, off in enumerate(xfade_offsets(n, hold, fade), start=1):
label = f"x{k}" if k < n - 1 else "vout"
chains.append(f"[{last}][v{k}]xfade=transition=fade:duration={fade}:offset={off}[{label}]")
last = label
if n == 1:
chains[-1] = chains[-1].rsplit("[v0]", 1)[0] + "[vout]"
cmd += ["-filter_complex", ";".join(chains), "-map", "[vout]"]
if audio:
cmd += ["-map", f"{n}:a", "-c:a", "aac", "-b:a", "128k"]
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
"-color_range", "tv", "-r", str(fps), "-movflags", "+faststart", "-t", str(total), str(out)]
return cmd
def build_slideshow(images, out, **kw) -> dict:
subprocess.run(build_command(images, out, **kw), check=True)
return probe(out)
def probe(path) -> dict:
data = json.loads(subprocess.run(
[FFPROBE, "-v", "error", "-count_frames", "-show_format", "-show_streams", "-of", "json",
str(path)], check=True, capture_output=True, text=True).stdout)
v = next(s for s in data["streams"] if s["codec_type"] == "video")
a = next((s for s in data["streams"] if s["codec_type"] == "audio"), None)
return {
"duration": float(data["format"]["duration"]),
"vduration": float(v["duration"]),
"frames": int(v["nb_read_frames"]),
"width": int(v["width"]), "height": int(v["height"]),
"fps": str(Fraction(v["r_frame_rate"])), "sar": v.get("sample_aspect_ratio", "1:1"),
"pix_fmt": v["pix_fmt"], "vcodec": v["codec_name"],
"acodec": a["codec_name"] if a else None,
"aduration": float(a["duration"]) if a else None,
}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("images", nargs="+")
ap.add_argument("-o", "--out", default="slideshow.mp4")
ap.add_argument("--hold", type=float, default=3.0)
ap.add_argument("--fade", type=float, default=0.5)
ap.add_argument("--fill", choices=["blur", "pad"], default="blur")
ap.add_argument("--size", default="1920x1080")
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--no-audio", action="store_true")
a = ap.parse_args()
w, h = (int(x) for x in a.size.split("x"))
print(json.dumps(build_slideshow(a.images, a.out, width=w, height=h, fps=a.fps, hold=a.hold,
fade=a.fade, fill=a.fill, audio=not a.no_audio), indent=1))
Run it:
python3 images_to_video.py landscape-input.jpg portrait-input.jpg product-input.jpg \
--fill blur --hold 3 --fade 0.5 -o slideshow.mp4
Measured on the three stills:
| Check | slideshow (blur) | slideshow (pad) |
|---|---|---|
| size / SAR | 1920×1080 / 1:1 | 1920×1080 / 1:1 |
| pix_fmt / range | yuv420p / tv | yuv420p / tv |
| frames | 240 | 240 |
| duration | 8.000 s | 8.000 s |
| audio | silent AAC, 8.000 s | none |
8.000 s is 3 × 3 − 2 × 0.5. If you write offset=3 and offset=6 instead, the graph still renders, but the fades land in the wrong place and the total length drifts. The arithmetic test below catches that before you look at any video.
Tests that catch the silent failures
def test_naive_scale_stretches_later_images(tmp_path):
...
# Every frame is locked to the first image's size: 9:16 stills get stretched 2.66x.
assert frame_sizes(out) == {(1620, 1080)}
def test_output_contract(blur):
out, p = blur
assert (p["width"], p["height"], p["fps"], p["sar"]) == (1920, 1080, "30", "1:1")
assert p["pix_fmt"] == "yuv420p" and p["frames"] == 240
assert abs(p["vduration"] - m.expected_duration(3, 3.0, 0.5)) < 1 / 30
assert frame_sizes(out) == {(1920, 1080)}
def test_pad_keeps_portrait_aspect(pad):
out, p = pad
# Portrait hold is 3.0-5.0 s. 1080x1920 fitted into 1080 high is 608 wide,
# so the outer columns must be black bars.
px = gray(out, 4.0)
assert col_mean(px, 0, 18) < 6 and col_mean(px, 46, 64) < 6
assert col_mean(px, 26, 38) > 40
The full file also checks: the odd-height failure, the TV-range tag, audio length against video length, the landscape bars, segment order (each segment's fitted region is matched against all three sources and must be closest to the right one), the crossfade midpoint lying between its neighbours, rejection of odd canvases, and a single-image run.
test_images_to_video.py::test_expected_duration_and_offsets PASSED
test_images_to_video.py::test_naive_image2_fails_on_odd_height PASSED
test_images_to_video.py::test_naive_scale_stretches_later_images PASSED
test_images_to_video.py::test_output_contract PASSED
test_images_to_video.py::test_limited_range_output PASSED
test_images_to_video.py::test_silent_audio_matches_video PASSED
test_images_to_video.py::test_pad_keeps_portrait_aspect PASSED
test_images_to_video.py::test_landscape_is_full_width PASSED
test_images_to_video.py::test_segments_follow_input_order PASSED
test_images_to_video.py::test_crossfade_is_a_blend PASSED
test_images_to_video.py::test_odd_canvas_rejected PASSED
test_images_to_video.py::test_single_image PASSED
12 passed in 6.08s
(FFmpeg 9.0.1 on macOS arm64, Python 3.13, pytest 9.2.)
Checklist before you upload
-
ffprobe -count_frames: one frame size only, SAR 1:1,yuv420p,color_range=tv. - Duration equals
n × hold − (n − 1) × fadeto within one frame. - Look at one frame from each portrait still. Round faces and straight label text mean no stretch.
- If the target needs an audio stream, add a silent track as long as the video.
- Big phone photos: downscale once before the graph. Decoding a 48 MP JPEG for every frame is slow.
Limits
This is a slideshow: the pixels inside each photo never move, apart from the crossfades. A slow zoompan push adds some motion, but it jitters unless you upscale first, and it still won't move hair, clouds or water. If you want that, you need a generative image-to-video model. For example, the Voor photo-to-video generator takes a first frame (and an optional last frame), a motion prompt, aspect ratio, 480p/720p/1080p and duration. Its defaults (Seedance 1.5 Pro, 480p, 5 s, audio on) showed 44 credits when I checked; it needs sign-in and credits. Run its output through the same ffprobe checks before it goes into this slideshow.


Top comments (0)