| name | ffmpeg-keyframe-extraction |
| version | 1.2.1 |
| description | Extract keyframes (I-frames) from video as still images with FFmpeg using select filter or -skip_frame nokey, with PTS-stamped filenames and ffprobe keyframe counting. Use when pulling thumbnails, scene-representative frames, or ML sampling frames out of MP4/MKV/AVI/WebM. Not for cutting or joining clips, scaling/watermarks/effects, codec or container changes, audio work, or reading stream metadata. |
| risk | safe |
| source | openrouter-deepsearch |
| date_added | 2026-06-16T00:00:00.000Z |
FFmpeg Keyframe Extraction
Isolate a video's I-frames and write them out as still images — the fast path to thumbnails, scene-representative previews, and cheap frame sampling for analysis or ML.
When to Use
Reach for this skill when you need the I-frames (intra-coded, "key" frames) of a video rather than every frame. An I-frame is a fully self-contained picture: unlike P- and B-frames, it does not depend on neighbouring frames to be decoded. That property makes keyframes the right unit of work for:
- Thumbnails and previews — each I-frame is a clean, complete image you can show directly.
- Scene / shot detection — encoders tend to place an I-frame at scene cuts, so the keyframe set is a cheap first approximation of "interesting moments."
- Sampling for analysis or ML — a 30-minute clip might hold 50,000 frames but only a few hundred I-frames, so working on keyframes cuts cost and storage by orders of magnitude while keeping visually distinct content.
When NOT to use
- You need every frame, or a fixed sampling rate. Keyframe spacing is decided by the encoder, not by you, so it is irregular (anywhere from one every few frames to one every few seconds). If you need uniform timing, extract by frame rate instead (
-vf fps=1 for one frame per second).
- You require exact source timestamps but want the
-skip_frame nokey fast path. The skip-frame fast path lets the decoder discard non-keyframes early, which is fast but gives coarser PTS information. When precise PTS preservation matters, use the select filter method, which decodes the full stream and reports accurate timestamps.
- The input is untrusted and you build the command by string concatenation. A filename like
"; rm -rf ~" is harmless as a literal argument but dangerous if a shell re-parses it. Always pass paths as separate, quoted arguments (shell) or as argv array elements (no shell: true).
- The content is DRM-protected / encrypted. FFmpeg will refuse to decode protected streams — this is a hard stop, not something to work around.
Route to sibling skills instead
| If the task is… | Use instead |
|---|
| Cutting, trimming, or concatenating video segments | ffmpeg-video-editing |
| Scaling, cropping, watermarking, speed, or visual effects | ffmpeg-video-filters |
| Changing container or codec (MKV→MP4, H.264→AV1, …) | ffmpeg-format-conversion |
| Extracting, normalizing, or mixing audio | ffmpeg-audio-processing |
| Inspecting duration, streams, codecs, or metadata | ffmpeg-media-info |
Prerequisites
- FFmpeg 7.0 or newer (the 8.x series is current in 2026) on
PATH. Verify with ffmpeg -version. The examples use -fps_mode, which replaced the deprecated -vsync flag in the 5.x series and is the supported spelling on 7.x and 8.x.
- A decodable input file (MP4, MKV, AVI, MOV, WebM, …). Non-DRM, readable by your user.
- A writeable output directory. The snippets create it for you, so a missing directory is not an error — but a read-only parent is.
- Windows host (PowerShell): All bash commands below have PowerShell equivalents noted inline. On Windows, use backtick (
`) for line continuation instead of backslash (\), and Test-Path / New-Item -ItemType Directory -Force instead of [[ -f ]] / mkdir -p.
Procedure
How the two methods differ
| Method | What FFmpeg does | Cost | When it wins |
|---|
select filter | Decodes all frames, then keeps only those where pict_type == I. | Slower (full decode). | You want accurate per-frame timestamps, or you may later add more filters. |
-skip_frame nokey | Tells the decoder to discard non-keyframes before fully decoding them. | Faster (skips most work). | Throughput matters more than exact PTS, e.g. bulk thumbnailing. |
Two critical details:
-skip_frame nokey is a decoder option, so it must appear before -i. Options that precede -i configure how that input is read/decoded. Put it after -i and it is interpreted as an output option, where it has no effect and you silently lose the speed-up.
- Use
-fps_mode vfr, not -vsync vfr. Both ask FFmpeg to keep a variable frame rate so it does not duplicate frames to hit a constant cadence (which would write many identical images). -vsync is deprecated; -fps_mode is its modern, supported replacement on 7.x.
We deliberately omit setpts=... from the still-image recipes. setpts renumbers each frame's PTS to a fresh sequence; that is useful when you re-encode selected frames back into a video stream, but for still images it would overwrite the very timestamps that -frame_pts 1 exists to expose.
Step 1 — Verify FFmpeg is available
ffmpeg -version
# PowerShell (Windows host — primary)
ffmpeg -version
Confirm the version line shows 7.0 or higher. If ffmpeg is not found, install it or add it to PATH before proceeding.
Step 2 — Validate the input file
input="sample_clip.mp4"
[[ -f "$input" && -r "$input" ]] || { echo "Input is not a readable file: $input" >&2; exit 2; }
# PowerShell
$input = "sample_clip.mp4"
if (-not (Test-Path $input -PathType Leaf)) { Write-Error "Input not found: $input"; exit 2 }
Step 3 — Create the output directory
mkdir -p ./keyframes
# PowerShell
New-Item -ItemType Directory -Force -Path .\keyframes | Out-Null
Step 4 — Extract keyframes
Method 1 — select filter (accurate timestamps, sequential names)
#!/usr/bin/env bash
set -euo pipefail
input="sample_clip.mp4"
output_dir="./keyframes"
command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not found in PATH" >&2; exit 127; }
[[ -f "$input" && -r "$input" ]] || { echo "Input is not a readable file: $input" >&2; exit 2; }
mkdir -p "$output_dir"
ffmpeg -hide_banner -loglevel error \
-i "$input" \
-vf "select='eq(pict_type,I)'" \
-fps_mode vfr \
"$output_dir/keyframe_%03d.png"
echo "Wrote $(find "$output_dir" -name 'keyframe_*.png' | wc -l) keyframe(s) to $output_dir"
# PowerShell equivalent
$input = "sample_clip.mp4"
$outDir = ".\keyframes"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
ffmpeg -hide_banner -loglevel error `
-i $input `
-vf "select='eq(pict_type,I)'" `
-fps_mode vfr `
"$outDir\keyframe_%03d.png"
(Get-ChildItem -Path $outDir -Filter "keyframe_*.png").Count
Method 2 — -skip_frame nokey (faster, sequential names)
#!/usr/bin/env bash
set -euo pipefail
input="sample_clip.mp4"
output_dir="./keyframes"
command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not found in PATH" >&2; exit 127; }
[[ -f "$input" && -r "$input" ]] || { echo "Input is not a readable file: $input" >&2; exit 2; }
mkdir -p "$output_dir"
ffmpeg -hide_banner -loglevel error \
-skip_frame nokey \
-i "$input" \
-fps_mode vfr -q:v 2 \
"$output_dir/keyframe_%03d.jpg"
echo "Wrote $(find "$output_dir" -name 'keyframe_*.jpg' | wc -l) keyframe(s) to $output_dir"
# PowerShell equivalent
$input = "sample_clip.mp4"
$outDir = ".\keyframes"
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
ffmpeg -hide_banner -loglevel error `
-skip_frame nokey `
-i $input `
-fps_mode vfr -q:v 2 `
"$outDir\keyframe_%03d.jpg"
(Get-ChildItem -Path $outDir -Filter "keyframe_*.jpg").Count
Variant — Encode the source timestamp into each filename
#!/usr/bin/env bash
set -euo pipefail
input="sample_clip.mp4"
output_dir="./keyframes"
command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not found in PATH" >&2; exit 127; }
[[ -f "$input" && -r "$input" ]] || { echo "Input is not a readable file: $input" >&2; exit 2; }
mkdir -p "$output_dir"
ffmpeg -hide_banner -loglevel error \
-i "$input" \
-vf "select='eq(pict_type,I)'" \
-fps_mode vfr -frame_pts 1 \
"$output_dir/keyframe_%d.png"
Step 5 — Count and verify I-frames with ffprobe
ffprobe -v error -select_streams v:0 -skip_frame nokey \
-show_entries frame=pict_type -of csv=p=0 "sample_clip.mp4" | grep -c '^I$'
# PowerShell equivalent
$iframeCount = (ffprobe -v error -select_streams v:0 -skip_frame nokey `
-show_entries frame=pict_type -of csv=p=0 "sample_clip.mp4" | Select-String '^I$').Count
Write-Host "I-frames found: $iframeCount"
Then compare against what you actually extracted:
find ./keyframes -name 'keyframe_*.png' | wc -l
# PowerShell
(Get-ChildItem -Path .\keyframes -Filter "keyframe_*.png").Count
The two numbers should match.
Key options reference
| Option | Example | Why it matters |
|---|
-i "sample_clip.mp4" | input file | Always quote the path so spaces / metacharacters stay literal (injection-safe). |
-vf "select='eq(pict_type,I)'" | I-frame filter | Keeps only frames whose picture type is I; the accurate-timestamp method. |
-skip_frame nokey | before -i | Decoder-level skip of non-keyframes; the fast method. Must precede -i. |
-fps_mode vfr | output | Variable frame rate — stops FFmpeg duplicating frames to a constant rate. Replaces -vsync vfr. |
-q:v 2 | JPEG quality | 2 (best) … 31 (worst). Applies to JPEG/MJPEG output; ignored for PNG/BMP. |
-frame_pts 1 | filename PTS | Writes the frame PTS into the numeric field of the output pattern. |
-noautorotate | before -i | Disables FFmpeg's default auto-rotation so frames keep the raw stored orientation. |
-hide_banner -loglevel error | global | Quiet output so automation only sees genuine errors. |
Orientation note. FFmpeg auto-rotates by default based on a video's display-matrix metadata, so extracted frames are already upright — you usually need to do nothing. Add -noautorotate (before -i) only when you specifically want the un-rotated, as-stored pixels.
Output filename patterns
The trailing argument is a printf-style pattern interpreted by the image2 muxer:
keyframe_%03d.png → keyframe_001.png, keyframe_002.png, … (zero-padded sequence).
keyframe_%03d.jpg → JPEG sequence; pair with -q:v to tune quality.
keyframe_%d.bmp → unpadded sequence of BMP images.
keyframe_%d.png with -frame_pts 1 → the number is the frame PTS, not a counter, so filenames reflect position in the source.
Programmatic usage (TypeScript / Node.js)
Load references/ for the full typed wrapper when integrating into a Node.js service. The wrapper validates options before spawning FFmpeg, runs FFmpeg without a shell (so filenames can never inject commands), enforces a timeout, and surfaces failures as specific, typed errors. There are no any types anywhere.
import { spawn } from "node:child_process";
import { access, mkdir } from "node:fs/promises";
import { constants as FS } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
export type ExtractionMethod = "select-filter" | "skip-frame";
export type OutputImageFormat = "png" | "jpg" | "bmp";
export interface KeyframeExtractionOptions {
readonly inputPath: string;
readonly outputDir: string;
readonly filenameStem?: string;
?: ;
?: ;
?: ;
?: ;
?: ;
?: ;
?: ;
}
{
: ;
: ;
: [];
: ;
}
{
() {
(message);
. = ;
}
}
{
: | ;
: . | ;
: ;
() {
(message);
. = ;
. = exitCode;
. = signal;
. = stderr;
}
}
{
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
: ;
}
= ;
: <> = ([, , ]);
: <> = ([, ]);
= ;
(): <> {
( options. !== || options..() === ) {
();
}
( options. !== || options..() === ) {
();
}
filenameStem = options. ?? ;
(!.(filenameStem)) {
(
+
,
);
}
format = options. ?? ;
(!.(format)) {
(
,
);
}
method = options. ?? ;
(!.(method)) {
(
,
);
}
jpegQuality = options. ?? ;
(!.(jpegQuality) || jpegQuality < || jpegQuality > ) {
(
,
);
}
timeoutMs = options. ?? ;
(!.(timeoutMs) || timeoutMs <= ) {
(
,
);
}
{
(options., .);
} {
(
,
);
}
{
: options.,
: options.,
filenameStem,
format,
method,
jpegQuality,
: options. ?? ,
: options. ?? ,
: options. ?? ,
timeoutMs,
};
}
(): [] {
: [] = [, , ];
(opts.) {
args.();
}
(opts. === ) {
args.(, );
}
args.(, opts.);
(opts. === ) {
args.(, );
}
args.(, );
(opts.) {
args.(, );
}
(opts. === ) {
args.(, (opts.));
}
args.(outputPattern);
args;
}
(): <> {
opts = (options);
(opts., { : });
outputPattern = (opts., );
args = (opts, outputPattern);
startedAt = performance.();
<>( {
child = (opts., args, { : });
stderr = ;
settled = ;
timer = ( {
(!settled) {
child.();
}
}, opts.);
child..();
child..(, {
(stderr. < ) {
stderr += chunk;
}
});
child.(, {
(settled) ;
settled = ;
(timer);
(
(, , , stderr),
);
});
child.(, {
(settled) ;
settled = ;
(timer);
(signal === ) {
(
(
,
code,
signal,
stderr.(),
),
);
;
}
(code !== ) {
(
(
,
code,
signal,
stderr.(),
),
);
;
}
({
: opts.,
outputPattern,
: args,
: performance.() - startedAt,
});
});
});
}
Programmatic usage (Python)
Load references/ for the full Python wrapper when integrating into a Python service. The wrapper uses subprocess.run with a list (not a string) to keep shell=False, validates options, and surfaces typed errors.
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ExtractionMethod(Enum):
SELECT_FILTER = "select-filter"
SKIP_FRAME = "skip-frame"
class OutputImageFormat(Enum):
PNG = "png"
JPG = "jpg"
BMP = "bmp"
class InvalidExtractionOptionsError(Exception):
pass
class FfmpegExecutionError(Exception):
def __init__(self, message: str, exit_code: int | None, stderr: str) -> None:
super().__init__(message)
self.exit_code = exit_code
self.stderr = stderr
@dataclass(frozen=True)
class KeyframeExtractionOptions:
input_path: Path
output_dir: Path
filename_stem: str = "keyframe"
method: ExtractionMethod = ExtractionMethod.SELECT_FILTER
image_format: OutputImageFormat = OutputImageFormat.PNG
jpeg_quality: =
embed_timestamp: =
disable_autorotate: =
ffmpeg_path: =
timeout_seconds: =
:
output_dir: Path
output_pattern: Path
command: [, ...]
_SAFE_STEM = re.()
() -> :
_SAFE_STEM.(options.filename_stem):
InvalidExtractionOptionsError(
)
( <= options.jpeg_quality <= ):
InvalidExtractionOptionsError(
)
options.timeout_seconds <= :
InvalidExtractionOptionsError()
shutil.which(options.ffmpeg_path) :
InvalidExtractionOptionsError(
)
options.input_path.is_file():
InvalidExtractionOptionsError(
)
() -> []:
command: [] = [options.ffmpeg_path, , , ]
options.disable_autorotate:
command.append()
options.method ExtractionMethod.SKIP_FRAME:
command += [, ]
command += [, (options.input_path)]
options.method ExtractionMethod.SELECT_FILTER:
command += [, ]
command += [, ]
options.embed_timestamp:
command += [, ]
options.image_format OutputImageFormat.JPG:
command += [, (options.jpeg_quality)]
command.append((output_pattern))
command
() -> KeyframeExtractionResult:
_validate(options)
options.output_dir.mkdir(parents=, exist_ok=)
output_pattern = (
options.output_dir /
)
command = _build_command(options, output_pattern)
:
completed = subprocess.run(
command,
check=,
capture_output=,
text=,
timeout=options.timeout_seconds,
)
FileNotFoundError exc:
FfmpegExecutionError(, , ) exc
subprocess.TimeoutExpired exc:
stderr = exc.stderr (exc.stderr, )
FfmpegExecutionError(
, , stderr
) exc
completed.returncode != :
FfmpegExecutionError(
,
completed.returncode,
completed.stderr.strip(),
)
KeyframeExtractionResult(
output_dir=options.output_dir,
output_pattern=output_pattern,
command=(command),
)
Pitfalls
-skip_frame nokey placed after -i does nothing. It is a decoder option; after -i it is read as an output option and silently ignored, so you lose the speed-up and wonder why the fast path is slow. Keep it before -i.
-vsync vfr is deprecated. It may still work on your build (with a warning), but -fps_mode vfr is the supported spelling and will not warn or break in future releases.
- Adding
setpts=... then expecting real timestamps in filenames. setpts rewrites each frame's PTS to a fresh sequence, which defeats -frame_pts 1. For still images, leave it out.
- Surprise rotation. FFmpeg auto-rotates by default, so a portrait phone video comes out upright. If you genuinely need the raw stored pixels, add
-noautorotate before -i — do not reach for a manual transpose filter unless the default truly does the wrong thing.
- Building the command with string interpolation. This re-introduces command injection. Pass paths as discrete, quoted arguments (shell) or argv array elements (the wrappers above).
- PowerShell line continuation. On Windows PowerShell, use backtick (
`) for line continuation, not backslash (\). Using \ will cause FFmpeg to receive a mangled command line.
- DRM-protected content. FFmpeg will refuse to decode protected streams. This is a hard stop — do not attempt to work around it.
Verification
Each check exists to catch a specific, real failure mode:
-
Count matches. The number of extracted files equals the ffprobe I-frame count. A mismatch usually means a wrong stream selector or a frame-rate flag duplicating/dropping frames.
ffprobe -v error -select_streams v:0 -skip_frame nokey \
-show_entries frame=pict_type -of csv=p=0 "sample_clip.mp4" | grep -c '^I$'
find ./keyframes -name 'keyframe_*.png' | wc -l
# PowerShell
$expected = (ffprobe -v error -select_streams v:0 -skip_frame nokey `
-show_entries frame=pict_type -of csv=p=0 "sample_clip.mp4" | Select-String '^I$').Count
$actual = (Get-ChildItem -Path .\keyframes -Filter "keyframe_*.png").Count
if ($expected -ne $actual) { Write-Error "Mismatch: expected $expected, got $actual" }
-
Timestamps present when requested. With -frame_pts 1, confirm filenames carry PTS values rather than a bare 1, 2, 3 counter — proof the flag took effect.
ls ./keyframes/keyframe_*.png | head -5
-
Images open cleanly. Spot-check a few PNG/JPEG files for corruption and correct orientation; this catches truncated writes and unexpected -noautorotate effects.
file ./keyframes/keyframe_001.png
-
FFmpeg version is ≥ 7.0 (ffmpeg -version), so -fps_mode and current decoder behaviour are guaranteed.
-
Output directory permissions are appropriate (commonly 0755, or stricter if the frames are sensitive), so downstream steps can read them and nothing leaks.
ls -ld ./keyframes
-
Input provenance is trusted. For files from untrusted sources, scan before processing and never interpolate the path into a shell string.
Related skills
ffmpeg-video-editing: Cut, trim, and concatenate segments. Keyframes matter there too — copy-mode cuts snap to them.
ffmpeg-video-filters: Scale, crop, or watermark; chain after select in the same -vf graph if extracted frames need resizing.
ffmpeg-format-conversion: Change container/codec; when re-encoding, -g and -force_key_frames control where future keyframes land.
ffmpeg-audio-processing: Extract or process audio tracks instead of video frames.
ffmpeg-media-info: Inspect streams and metadata; the ffprobe counting recipe above is the boundary between the two skills.