DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Extract Frames From a Video With FFmpeg

To extract frames from a video with FFmpeg, pick the command by what you need: -ss T -i in.mp4 -frames:v 1 for one exact moment, -vf "select=..." for one real frame every N seconds, -fps_mode passthrough for every frame, and select='gt(scene,0.3)' for cuts. The popular one-liner -vf fps=1 works, but it does not return the frames at 0 s, 1 s, 2 s: on a 24 fps clip it returned frames 11, 35, 59 … and silently dropped the last second.

I measured all of this by pixel-hashing every PNG FFmpeg wrote against frames decoded by index, on five real 6-second AI-generated clips. The code and the 33 pytest checks are below.

Contact sheet of seven real frames at 0 to 6 seconds extracted with the FFmpeg select filter from a 24 fps Grok Imagine Video 1.5 sailboat clip

The test clips

Five public example clips from Grok Imagine Video 1.5 (xAI's video model, as published on Voor's model page): 145 frames each, 24 fps, 6.04 s, H.264 with AAC audio, four at 1280×720 and one portrait at 480×848. Generated video is a good stress case: it is one continuous shot, and it has exactly one keyframe (frame 0), which matters below.

ffprobe -v error -select_streams v:0 -count_frames \
  -show_entries stream=avg_frame_rate,nb_read_frames:format=duration \
  -of default=nw=1 g-sailboat-i2v.mp4
# avg_frame_rate=24/1  nb_read_frames=145  duration=6.041667
Enter fullscreen mode Exit fullscreen mode

1. One frame at an exact time: -ss before -i

ffmpeg -ss 2.5 -i clip.mp4 -frames:v 1 at_2.5s.png
Enter fullscreen mode Exit fullscreen mode

With -ss before -i, FFmpeg seeks to the nearest earlier keyframe and then decodes forward to the requested time. Despite the old "input seeking is inaccurate" advice, on FFmpeg 9.0.1 it was frame-exact in every test: the output is the first frame whose timestamp is at or after T. 0 → frame 0, 1.0 → 24, 2.5 → 60, 6.0 → 144.

Seeking cost grows with keyframe distance. These clips have a single keyframe, so every seek decodes from frame 0; on a 6-second clip that is nothing, but on a long file with sparse keyframes each seek decodes much more.

2. One frame every N seconds: prefer select over fps

Timeline of which frame indices fps=1, fps round=down, select, -ss and skip_frame nokey return from a 145-frame 24 fps clip

The chart shows which frames each command really returned (1-second interval, 145-frame clip):

Command Frames out Frame indices
-vf fps=1 6 11, 35, 59, 83, 107, 131
-vf fps=1:round=down 6 23, 47, 71, 95, 119, 143
-vf "select='isnan(prev_selected_t)+gte(t-prev_selected_t\,1)'" 7 0, 24, 48, 72, 96, 120, 144

The fps filter resamples time. Each output frame represents a whole one-second bucket, so with the default round=near you get the middle of each second, and the final partial second (6.00–6.04 s) produces no output. That is fine for a contact sheet. It is wrong if you need "the frame at 3 s" or the last frame of the clip.

select keeps the first real frame at or after 0, N, 2N … and nothing else:

ffmpeg -i clip.mp4 \
  -vf "select='isnan(prev_selected_t)+gte(t-prev_selected_t\,1)'" \
  -fps_mode passthrough every_%04d.png
Enter fullscreen mode Exit fullscreen mode

-fps_mode passthrough (the modern name for -vsync passthrough) matters: without it the image2 muxer can duplicate frames to fill the gaps.

3. Every frame

ffmpeg -i clip.mp4 -fps_mode passthrough frame_%05d.png
Enter fullscreen mode Exit fullscreen mode

145 frames in, 145 PNGs out, and the last PNG hashed identical to frame 144. At 1280×720 that was roughly 0.6 MB per PNG; use -q:v 2 frame_%05d.jpg if you only need previews.

4. Only at scene changes

ffmpeg -i clip.mp4 -vf "select='gt(scene,0.3)',showinfo" -an -f null - 2>&1 | grep pts_time
Enter fullscreen mode Exit fullscreen mode

None of the five single-shot clips produced a detection at 0.3. Joining two clips (fox, then sailboat) with the concat demuxer produced exactly one, at 6.063 s, within one frame of the join at 145/24 s. Scene detection finds cuts, not motion; a sweeping camera move in one shot did not trigger it.

5. Why not keyframes?

-skip_frame nokey is the fastest way to grab "a few frames", because it decodes nothing but keyframes. On these AI clips it returned a single frame: there is only one keyframe in six seconds. Encoders put keyframes where it suits compression, not where your content changes. Do not use it for sampling.

The module

# extract_frames.py (abridged)
def at_timestamps(path, out_dir, times):
    info = probe(path)
    for t in times:
        if not 0 <= t < info.duration:
            raise ValueError(f"timestamp {t} outside 0..{info.duration:.3f}s")
        _run(["ffmpeg", "-v", "error", "-y", "-ss", f"{t:.6f}", "-i", str(path),
              "-frames:v", "1", str(Path(out_dir) / f"at_{t:09.3f}s.png")])

def every_n_seconds_select(path, out_dir, interval=1.0):
    expr = f"isnan(prev_selected_t)+gte(t-prev_selected_t\\,{interval})"
    _run(["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vf", f"select='{expr}'",
          "-fps_mode", "passthrough", str(Path(out_dir) / "sel_%04d.png")])

def scene_changes(path, threshold=0.3):
    p = subprocess.run(["ffmpeg", "-hide_banner", "-i", str(path),
                        "-vf", f"select='gt(scene,{threshold})',showinfo", "-an", "-f", "null", "-"],
                       capture_output=True, text=True, check=True)
    return [float(t) for t in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
Enter fullscreen mode Exit fullscreen mode

To prove which frame a PNG is, hash its decoded pixels and compare with frame k rendered through the same PNG encoder (comparing raw rgb24 from the video against a PNG gives false mismatches because the colour conversion path differs):

def frame_md5(path=None, index=None, image=None):
    if image is None:
        with tempfile.TemporaryDirectory() as tmp:
            image = Path(tmp) / "ref.png"
            _run(["ffmpeg", "-v", "error", "-y", "-i", str(path),
                  "-vf", f"select=eq(n\\,{index})", "-frames:v", "1",
                  "-fps_mode", "passthrough", str(image)])
            return frame_md5(image=image)
    return _run(["ffmpeg", "-v", "error", "-i", str(image), "-f", "md5", "-"]).stdout.split("=")[-1].strip()
Enter fullscreen mode Exit fullscreen mode

The tests

@pytest.mark.parametrize("rounding,frames", [("near", [11, 35, 59, 83, 107, 131]),
                                              ("down", [23, 47, 71, 95, 119, 143])])
def test_fps_filter_samples_are_not_on_the_second(tmp_path, rounding, frames):
    files = every_n_seconds(CLIP, tmp_path, 1.0, rounding)
    assert [frame_md5(image=f) for f in files] == [frame_md5(CLIP, k) for k in frames]

def test_select_samples_are_exact_frames(tmp_path):
    files = every_n_seconds_select(CLIP, tmp_path, 1.0)
    for k, f in enumerate(files):
        assert frame_md5(image=f) == frame_md5(CLIP, 24 * k)

@pytest.mark.parametrize("t", [0.0, 1.0, 2.5, 4.0, 6.0])
def test_at_timestamp_is_frame_accurate(tmp_path, t):
    (f,) = at_timestamps(FOX, tmp_path, [t])
    assert frame_md5(image=f) == frame_md5(FOX, math.ceil(round(t * 24, 6)))

def test_scene_detection_finds_the_join(tmp_path):
    joined = concat([FOX, CLIP], tmp_path / "joined.mp4")
    (cut,) = scene_changes(joined, 0.3)
    assert abs(cut - 145 / 24) < 1 / 24 + 1e-6
Enter fullscreen mode Exit fullscreen mode
$ pytest -q test_extract_frames.py
33 passed in 17.25s
Enter fullscreen mode Exit fullscreen mode

Checklist

  • Need an exact moment? -ss T before -i, -frames:v 1.
  • Need one frame per interval that lands on the interval? select with prev_selected_t, plus -fps_mode passthrough.
  • Need an even contact sheet and do not care about exact times? fps=1/N is fine; expect mid-interval frames and no final partial interval.
  • Need cuts? select='gt(scene,0.3)', and test the threshold on a known join.
  • Never assume keyframes are evenly spaced.

Where the clips came from

If you want your own short test clips, these came from the Grok Imagine Video 1.5 page on Voor, which makes 1–15 second clips with generated sound from a prompt, a still, or up to seven reference images; the page lists six seconds at 720p at 63 credits on Lite and 293 on Standard. I used the published examples rather than generating new ones. You can try the same model on Voor and run the extraction tests against your own output, or open the generator when you need a fresh fixture with a known frame count.

Top comments (0)