Build ffmpeg filter graphs without writing them by hand.
Reframe a clip, key out a green screen, put text on it, burn in subtitles.
Four operations that turn into a -filter_complex string nobody wants to
maintain by hand.
That picture is generated by examples/make_demo.py
from nothing but ffmpeg test patterns, and this is the code behind the
right-hand frame:
from ffgraph import Clip
(
Clip.open("source.mp4")
.reframe(1080, 1920)
.overlay("banner.mp4", chromakey="0x00B140", width=980, y=180, loop=True)
.text("Part 2: it's here, [100%]", font="DejaVuSans.ttf", size=58, y="h*0.62", box=True)
.subtitles("subs.ass", fonts_dir="/usr/share/fonts")
.render("out.mp4")
)The filters are documented. The ways they quietly ruin an afternoon are not.
- A looped overlay without
shortest=1never ends.-stream_loop -1makes the banner infinite, and ffmpeg will cheerfully encode until the disk fills up. Here the two are set together or not at all. drawtextcan accept a caption and draw nothing. An apostrophe or a percent sign is enough, with no warning and exit code zero.- Blurring a background at full size costs four times what it needs to. It is about to be blurred beyond recognition, so it is done at half size.
- A mistyped label is a broken graph. Threading
[v0]into[v1]into[v2]by hand is where the afternoon actually goes.
ffgraph builds the graph, and command() hands it back unrun — so when
ffmpeg does complain, you can see exactly what it was given:
>>> Clip.open("in.mp4").reframe(1080, 1920).command("out.mp4")
['ffmpeg', '-y', '-i', 'in.mp4', '-filter_complex',
'[0:v]split=2[_bg][_fg];'
'[_bg]scale=540:960:force_original_aspect_ratio=increase,crop=540:960,'
'gblur=sigma=8,scale=1080:1920:flags=bilinear[_bgd];'
'[_fg]scale=1080:1920:force_original_aspect_ratio=decrease:flags=lanczos[_fgd];'
'[_bgd][_fgd]overlay=(W-w)/2:(H-h)/2,setsar=1[v0]', '-map', '[v0]', ...]pip install git+https://github.com/mishalkis/ffgraphNo dependencies beyond the standard library — ffmpeg does the work. Python
3.10 or newer, with ffmpeg on PATH.
clip.reframe(1080, 1920, blur_background=True, focus_x=None)
clip.overlay(path, chromakey=None, similarity=0.15, width=None, x="center", y=0, loop=False)
clip.text(text, font=None, size=64, x="center", y=64, start=None, end=None, box=False)
clip.subtitles(path, fonts_dir=None)Each returns a new Clip, so a base can be reused:
base = Clip.open("talk.mp4").reframe(1080, 1920)
base.text("Part 1", end=3).render("part1.mp4")
base.text("Part 2", end=3).render("part2.mp4")reframe fits the whole picture into the new shape and fills the bands
left over with a blurred copy of itself, so nothing is cropped away. Pass
blur_background=False to crop to the frame instead, and focus_x (0..1) to
say which part of the picture the crop should keep.
overlay puts another file on top, optionally keying a colour out of it
first. similarity is how close a pixel must be to count as the key colour —
raise it if fringes survive, but not far: at 0.3 a black box on a green
background vanishes along with the green.
text takes the caption as you want it read. Positions accept "center",
a pixel count, or any ffmpeg expression such as "h*0.62".
subtitles burns an ASS/SSA file in through libass. Point fonts_dir at
the fonts it needs; a container rarely has them installed.
Captions travel as files. ffmpeg's escaping for inline text= cannot
express an apostrophe: quoted or bare, backslashed or doubled, no form I could
find both parses and draws. Written to a file and read back with
textfile=, a caption is simply the bytes you wrote. The tests check pixels
rather than exit codes, because that is where this failure hides.
expansion=none is always set. Otherwise drawtext reads the caption as a
date format, and 100% renders as an empty frame.
Paths are quoted, not escaped. A directory called season 1, part 2 is
fine. A path containing an apostrophe is not supported, for the reason above.
pip install -e ".[dev]"
pytestTests that run ffmpeg skip themselves when it is missing. The rest check the generated graph as a string and need nothing installed.
