Project 02 · Python

Frame Extractor

Extract frames from a video as PNG or JPEG, from the command line or from Python. Built on ffmpeg, and written because ffmpeg accepts several requests it cannot satisfy and reports success anyway.

Problem

Exit zero, wrong answer

For a one-off extraction by someone who knows ffmpeg, a wrapper adds very little. The whole job is one command. What it does add is a guard against the cases where ffmpeg exits zero and produces output that looks reasonable.

What you ask for ffmpeg frame-extractor
--start 99 on a 2-second video writes nothing, exits 0 rejected, exit 1
--jpeg-quality 100, valid range 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
--scale -1:-1 resizes nothing rejected, exit 1
--fps 20 on a 10fps source 40 files, every second one a duplicate rejected, exit 1

The third row is the one that bites. Frames from two runs end up in one directory with nothing marking which is which, and the count you get back is wrong rather than merely untidy.

Approach

Refuse before running, not after

Every one of those cases is knowable before ffmpeg starts. The source is probed first, the request is checked against what the file can actually provide, and anything impossible is rejected with a single error: line on stderr and a non-zero exit. No tracebacks.

the first row, run
# ffmpeg: start past the end of a 2-second clip
$ ffmpeg -ss 99 -i in.mp4 -vsync 0 frames/f_%06d.png
$ echo $?
0 # and frames/ is empty

$ frame-extractor in.mp4 frames/ --start 99
error: --start (99.0s) is at or past the end of the video (2.000s)
EXIT 1 rejected, as intended

The same checks back the Python API, which raises FrameExtractorError subclasses rather than printing — the library never writes to stdout, so what to do with a failure stays with the caller.

Evidence

What is actually verified

Tested
Python 3.10 through 3.14
CI runs the full matrix on every pull request. The suite generates its own clip with ffmpeg's testsrc, so no fixture video is committed.
Dependencies
None beyond ffmpeg itself
No third-party Python packages — see pyproject.toml.
Checked
Lint, formatting, types, tests
make check runs locally exactly what CI runs.
Documented
Design notes in the repository
DESIGN.md explains why the ffmpeg invocation looks the way it does, for whoever changes a flag next.

Scope

Where it does not help

Frames as arrays for machine learning or computer vision work are better served by PyAV or TorchCodec, rather than writing PNGs only to read them straight back. Anything past extraction — trimming, concatenating, re-encoding — is ffmpeg's job and stays there.

Two of the frame-selection modes can return far fewer frames than expected, and that is correct rather than broken. Scene detection is a heuristic on pixel differences, not a cut list; in a test clip of four solid colours it found two of the three changes. The command line says so when a run writes nothing.