frame-extractor
Extract every frame of a video within a given time range as individual PNG or JPEG images, using ffmpeg. Usable as a command-line tool or as a Python library.
Why not just use ffmpeg?
For a one-off extraction by someone who knows ffmpeg, this adds very little. The whole job is one command:
ffmpeg -ss 10 -i in.mp4 -t 5 -vsync 0 frames/f_%06d.png
What it does add is a guard against ffmpeg's silent wrong answers. Each of these exits 0 and produces output that looks reasonable:
| What you ask for | ffmpeg | frame-extractor |
|---|---|---|
-ss 99 on a 2-second video |
writes nothing, exits 0 | rejected, exit 1 |
-q:v 100 (valid range is 2–31) |
clamps to 31 silently | rejected, exit 1 |
| re-extracting a shorter range | leaves the previous run's surplus frames alongside the new ones | refused unless --overwrite, which clears them first |
scale=-1:-1 |
resizes nothing | rejected, exit 1 |
fps=20 on a 10fps source |
40 files, every second one a duplicate | rejected, exit 1 |
None of these are misconfigurations — it's ffmpeg working as designed, being a low-level tool that does what it is told. Catching them needs a layer above: probe the duration first, range-check the quality, clear the directory before writing. That layer is what this is.
The third row is the one that bites hardest. Frames from two different runs end up in one directory with nothing marking which is which, and the count you get back is wrong rather than merely untidy.
The other half is the Python API. If you need frames from inside a program, the alternative is writing the subprocess wrapper yourself — and then rediscovering the five rows above one at a time.
Where it doesn't help
- Frames as arrays for ML or CV work — use PyAV or TorchCodec. Writing PNGs only to read them back pays for an encode and a decode you don't need.
- Anything beyond extraction — trimming, concatenating, re-encoding — is ffmpeg's job, and this deliberately doesn't grow into a general wrapper.
Requirements
- Python 3.10 or newer
ffmpegandffprobeavailable onPATH— both ship together in every ffmpeg distribution, so one install covers them
# Debian / Ubuntu / WSL
sudo apt update && sudo apt install ffmpeg
# macOS
brew install ffmpeg
Check the install with ffmpeg -version.
On WSL, a Windows-side ffmpeg installation won't satisfy this — the Linux package is what ends up on your
PATH.
There are no third-party Python dependencies.
Install
pip install frame-extractor-ffmpeg
The distribution is named frame-extractor-ffmpeg because frame-extractor
was already taken on PyPI by an unrelated package. It installs the
frame-extractor command and the frame_extractor module, so only the
pip install line carries the longer name.
From a clone instead:
git clone https://github.com/jgonmor16/frame-extractor.git
cd frame-extractor
make install
make install is an editable install with the development dependencies.
Run make on its own to see the other targets.
Command-line usage
frame-extractor VIDEO OUTPUT_DIR [--start SECONDS] [--end SECONDS]
[--format {png,jpg}] [--jpeg-quality N]
[--fps N | --keyframes | --scenes THRESHOLD]
[--scale W:H] [--no-progress] [--overwrite]
| Argument | Required | Default | Meaning |
|---|---|---|---|
VIDEO |
yes | — | Path to the input video file |
OUTPUT_DIR |
yes | — | Directory for the frames; created if missing |
--start |
no | 0.0 |
Start of the range in seconds, inclusive |
--end |
no | end of video | End of the range in seconds, exclusive |
--format |
no | png |
Output image format: png or jpg |
--jpeg-quality |
no | 2 |
JPEG quality, 2 (best) to 31 (worst); ignored for PNG |
--fps |
no | every frame | Frames to extract per second of video |
--keyframes |
no | off | Extract only the video's key frames |
--scenes |
no | off | Extract only frames where the picture changes |
--scale |
no | source size | Output size as WIDTH:HEIGHT |
--no-progress |
no | off | Suppress the progress indicator |
--overwrite |
no | off | Replace frames from an earlier extraction |
Examples
# Every frame of the whole video
frame-extractor input.mp4 frames/
# Five seconds, starting at ten
frame-extractor input.mp4 frames/ --start 10 --end 15
# From the 30-second mark to the end
frame-extractor input.mp4 frames/ --start 30
# One frame per second instead of all of them
frame-extractor input.mp4 frames/ --fps 1
# One frame every four seconds, for a rough overview
frame-extractor input.mp4 frames/ --fps 0.25
# Only the key frames: fastest way to see what is in a long file
frame-extractor input.mp4 frames/ --keyframes
# Only where the picture changes, to find the cuts
frame-extractor input.mp4 frames/ --scenes 0.4
# Resize to a fixed size, for a model expecting one
frame-extractor input.mp4 frames/ --scale 224:224
# Fixed width, height following the aspect ratio
frame-extractor input.mp4 frames/ --scale 640:auto
# JPEG instead of PNG, trading fidelity for disk space
frame-extractor input.mp4 frames/ --format jpg --jpeg-quality 10
# Re-extract a different range into a directory already holding frames
frame-extractor input.mp4 frames/ --start 5 --end 8 --overwrite
The range is half-open — a frame landing exactly on --end is excluded, so
--start 0 --end 1 and --start 1 --end 2 produce no overlap.
Library usage
from pathlib import Path
from frame_extractor import extract_frames, FrameExtractorError
try:
frames = extract_frames(
Path("input.mp4"),
Path("frames"),
start_time=10.0,
end_time=15.0,
image_format="jpg",
jpeg_quality=10,
fps=1.0,
scale="640:auto",
overwrite=False,
)
except FrameExtractorError as exc:
print(f"extraction failed: {exc}")
else:
print(f"wrote {len(frames)} frames, first is {frames[0].name}")
extract_frames returns a sorted list[Path], so the frames come back in
playback order and can be fed straight into whatever comes next.
Pass on_progress to follow a long extraction. The library never prints, so
what to do with each update is yours to decide:
def show(progress):
print(f"{progress.fraction:.0%} · {progress.frames_written} frames")
extract_frames(video, out, fps=1.0, on_progress=show)
Each update carries seconds_done, seconds_total, frames_written, and a
fraction property that is None when the total isn't known.
Public API
Everything below is importable from frame_extractor directly. Anything not
listed is an implementation detail and may move between versions.
extract_frames takes the source, the destination, and the time range
positionally; every option after those is keyword-only. That keeps the common
call short while making longer ones self-describing, and means new options can
be added without their position becoming part of the API.
extract_frames(video, out, 10.0, 15.0, fps=1.0, scale="640:auto")
| Name | |
|---|---|
extract_frames |
The extraction function |
FrameExtractorError |
Base class — catch this to handle any expected failure |
VideoFileError |
Input missing, or unreadable as media |
InvalidTimeRangeError |
Range negative, inverted, or starting past the end |
InvalidOutputOptionError |
Unsupported format, or quality out of range |
OutputDirectoryError |
Output directory holds frames and overwrite is False |
FFmpegNotFoundError |
ffmpeg or ffprobe missing from PATH |
FFmpegExecutionError |
ffmpeg exited non-zero; carries returncode and stderr |
SUPPORTED_FORMATS |
("png", "jpg") |
MIN_JPEG_QUALITY / MAX_JPEG_QUALITY |
2 and 31 |
Output
Frames are written as zero-padded images in the chosen format, numbered from 1 in playback order:
frames/
├── frame_000001.png
├── frame_000002.png
├── frame_000003.png
└── ...
Numbering always restarts at 000001 for each run, regardless of --start.
PNG is the default because it's lossless, which is usually what you want for
frame analysis. Use --format jpg when the frame count is large enough that
disk space matters more than fidelity.
Sampling instead of every frame
Extracting every frame is rarely what you want. Thirty seconds of 640x480
footage at 30fps is 900 files and 31 MB; at --fps 1 it is 30 files and
1.1 MB. For dataset building, thumbnails, or scene overviews, a rate is
usually closer to the real requirement than exhaustive extraction.
Fractional rates work, so --fps 0.25 gives one frame every four seconds. A
rate above the source's own frame rate is rejected: ffmpeg would duplicate
frames rather than find new ones. Asking for 30 against 29.97fps footage is
fine, since a small tolerance treats that as a rounding difference.
Choosing frames another way
--fps samples at a rate you pick. Two other modes let the video decide
instead, and only one selection mode can be used at a time.
--keyframes extracts the frames the encoder stored as complete pictures.
Everything between them is stored as differences, so skipping them means never
decoding them at all — on a two-minute file that is 0.3 seconds against 27 for
every frame. The trade is that their spacing is the encoder's choice, not
yours: it might be one every two seconds or one every ten, and it varies
within a file.
--scenes THRESHOLD keeps frames where the picture differs from the one
before it by more than the threshold, from 0 to 1. Around 0.4 catches clear
cuts.
Both return fewer frames than you might expect, and that is not a failure.
Scene detection especially is a heuristic on pixel differences rather than a
cut list: it misses cross-fades, gradual transitions, and cuts between shots
that happen to be numerically similar. In a test clip of four solid colours it
found two of the three changes. Check the threshold against your own footage
before trusting it, and use --fps when you need a predictable number of
frames.
Resizing
--scale WIDTH:HEIGHT resizes during extraction rather than in a second pass
over the files, which for a model expecting a fixed input size saves decoding
and re-encoding every frame.
Either side may be auto to derive it from the other and the source aspect
ratio, so --scale 640:auto fixes the width and lets the height follow.
Deriving both is rejected: ffmpeg accepts it and silently leaves the size
unchanged.
ffmpeg's own -1 and -2 spellings work too, but note that a value starting
with a dash has to be written --scale=-1:240, since argparse otherwise reads
it as a flag. auto avoids that.
Progress
A long extraction draws a single line on stderr, rewritten in place, and cleared before the summary:
52% 63 frame(s)
It appears only when stderr is a terminal, so piping or redirecting output
suppresses it without a flag. --no-progress turns it off in a terminal too.
The percentage is of the requested range, not the whole file, and comes from the duration already probed — nothing extra is decoded to produce it.
Re-running into the same directory
By default, extracting into a directory that already holds frame_* files of
the same format is an error. Passing --overwrite deletes those files
before extracting, rather than writing over them — ffmpeg renumbers from 1 on
every run, so a shorter second extraction would otherwise leave the tail of the
first behind. Only files matching this run's own frame_*.<format> pattern are
removed; anything else in the directory is left alone.
Errors
Failures print a single error: line to stderr and exit non-zero — no
tracebacks.
| Exit code | Meaning |
|---|---|
0 |
Frames extracted successfully |
1 |
The request or the environment was rejected (see below) |
2 |
Bad command-line usage, reported by argparse |
What can go wrong, and what it looks like:
error: Video file not found: input.mp4
error: --end (1.0) must be greater than --start (5.0)
error: --start (99.0s) is at or past the end of the video (2.000s) ...
error: Unsupported format 'bmp'; expected one of png, jpg
error: --jpeg-quality must be between 2 (best) and 31 (worst), got 100
error: 'frames' already holds 20 file(s) matching 'frame_*.png'. Pass ...
error: ffprobe could not read 'broken.mp4': ...
error: ffmpeg and ffprobe were not found on PATH. Install ffmpeg with ...
error: --fps (20.0) is above the video's own rate of 10.000 ...
error: --scale must be WIDTH:HEIGHT, got 'abc'. Use 'auto' ...
error: --scale 'auto:auto' derives both dimensions from each other ...
When ffmpeg or ffprobe fails, its own diagnosis is printed beneath the summary line rather than discarded.
All of these are FrameExtractorError subclasses, so a single
except FrameExtractorError catches every expected failure.
How it works
frame_extractor validates the request, asks ffprobe how long the video is,
then builds a single ffmpeg invocation and runs it via subprocess.
Several details are deliberate:
-ssis placed before-i, so ffmpeg seeks on the input rather than decoding and discarding everything up to the start point. Since ffmpeg 2.1 this is frame-accurate as well as fast — the test suite verifies it by digest, comparing a seeked frame against the same frame from a full extraction.- The clip length is passed as a duration (
-t), not an end TS (-to). When-ssprecedes-i, ffmpeg interprets-torelative to the seek position, which is a common source of clips that end in the wrong place. -vsync 0passes every decoded frame straight through, so nothing is duplicated or dropped. The file count matches the source's real frame count for the range, when no rate is requested.ffprobesupplies the duration so a start time past the end of the file is rejected outright. Without it, that case ran to completion, wrote nothing, and reported success.--jpeg-qualityis range-checked before ffmpeg runs, because ffmpeg silently clamps values outside 2–31 rather than complaining. Passing100would otherwise have quietly produced the worst setting.- The output directory is prepared in Python, not by ffmpeg's
-n. With numbered output patterns,-nsilently skips and exits zero, which would hide exactly the situation the overwrite guard exists to report. fpsprecedesscalein the filter chain, so resampling happens first and the scaler handles the sampled frames rather than all of them.--scaleis matched against a strictWIDTH:HEIGHTpattern, because the value is interpolated into ffmpeg's-vfargument and an unchecked comma would append filters the caller never asked for.
Omitting --end simply leaves -t off the command, so ffmpeg runs to the end
of the file. An --end beyond the real duration needs no special handling
either — ffmpeg stops at the end of the input.
Layout
src/frame_extractor/
├── __init__.py public API re-exports
├── cli.py argument parsing and exit codes
├── extractor.py extraction logic; no argparse, no stdout
├── ffmpeg_utils.py binary discovery and video probing
└── exceptions.py the FrameExtractorError hierarchy
The library holds no argparse, no stdout, and no exit codes — those belong to
cli.py, which is itself just another consumer of the public API.
Testing
make install
make test
make check runs everything CI does: lint, formatting, types, and tests.
The suite generates its own sample clip with ffmpeg's testsrc source, so
there's no fixture video in the repository. Tests covering argument validation
and ffmpeg command construction run anywhere; those needing real decoding skip
automatically if ffmpeg isn't installed.
CI runs the same suite against Python 3.10 through 3.14 on every pull request.
Development
The Makefile wraps the commands CI runs, so the two cannot drift apart:
| Target | |
|---|---|
make install |
Install the package with its development dependencies |
make test |
Run the test suite |
make lint |
Report lint and formatting problems, changing nothing |
make format |
Apply ruff's fixes and formatting |
make typecheck |
Run mypy |
make check |
Everything CI runs: lint, typecheck, test |
make build |
Build the sdist and wheel into dist/ |
make clean |
Remove build artefacts and tool caches |
Run make check before pushing. Releases are published to PyPI automatically
when a GitHub release is created, via trusted publishing.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file frame_extractor_ffmpeg-1.2.0.tar.gz.
File metadata
- Download URL: frame_extractor_ffmpeg-1.2.0.tar.gz
- Upload date:
- Size: 31.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3f00b347177c2c1026363b8d2d737f027bee57a7547cf025b5cbd24c2fdd391a
|
|
| MD5 |
fd121ee190001fd32a6b1db2d87dd4c8
|
|
| BLAKE2b-256 |
63ba45e9395fad76f4d2cf3f2b6882a3e3424657207ec3c7547683384286c057
|
Provenance
The following attestation bundles were made for frame_extractor_ffmpeg-1.2.0.tar.gz:
Publisher:
publish.yml on jgonmor16/frame-extractor
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
frame_extractor_ffmpeg-1.2.0.tar.gz -
Subject digest:
3f00b347177c2c1026363b8d2d737f027bee57a7547cf025b5cbd24c2fdd391a - Sigstore transparency entry: 2422647255
- Sigstore integration time:
-
Permalink:
jgonmor16/frame-extractor@1c0a08354734e2f85764b11db2c95ef996d1c5a8 -
Branch / Tag:
refs/tags/v1.2.0 - Owner: https://github.com/jgonmor16
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1c0a08354734e2f85764b11db2c95ef996d1c5a8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file frame_extractor_ffmpeg-1.2.0-py3-none-any.whl.
File metadata
- Download URL: frame_extractor_ffmpeg-1.2.0-py3-none-any.whl
- Upload date:
- Size: 20.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f00eda0341c2f3561b381a50c89a55229bdfe9c2d4cd4bf0cf856bb0f4f39fe
|
|
| MD5 |
784e76a1baa505c10727e931aab7388c
|
|
| BLAKE2b-256 |
71d230234ca1a95dce6a2315237217c0d44a31cdf7612825c31555fa41b0945d
|
Provenance
The following attestation bundles were made for frame_extractor_ffmpeg-1.2.0-py3-none-any.whl:
Publisher:
publish.yml on jgonmor16/frame-extractor
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
frame_extractor_ffmpeg-1.2.0-py3-none-any.whl -
Subject digest:
7f00eda0341c2f3561b381a50c89a55229bdfe9c2d4cd4bf0cf856bb0f4f39fe - Sigstore transparency entry: 2422647336
- Sigstore integration time:
-
Permalink:
jgonmor16/frame-extractor@1c0a08354734e2f85764b11db2c95ef996d1c5a8 -
Branch / Tag:
refs/tags/v1.2.0 - Owner: https://github.com/jgonmor16
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1c0a08354734e2f85764b11db2c95ef996d1c5a8 -
Trigger Event:
release
-
Statement type: