DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Create a Video From Images With FFmpeg (Without Stretching Portrait Photos)

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.

Three real stills of different shapes fitted into one 1920x1080 slideshow with blurred fill, crossfade timeline and measured ffprobe values

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Decoded frames of the naive one-liner showing stretched portrait and product images, compared with the padded output that keeps the aspect ratio

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:

  1. Fit, don't force. scale=1920:1080:force_original_aspect_ratio=decrease:force_divisible_by=2 keeps the shape and guarantees even sides.
  2. Fill the rest. pad gives black bars. The blur mode puts a cover-cropped, boxblurred copy of the same image behind it, which is what most vertical-photo slideshows use.
  3. Normalize. setsar=1, out_range=tv, format=yuv420p, fps=30.
  4. Bound each still. -loop 1 is infinite; trim=duration=3 plus setpts=PTS-STARTPTS makes it a 3-second clip.
  5. Join with xfade. Its offset is on the output timeline, so the k-th fade starts at k × (hold − fade), not k × 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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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) × fade to 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)