Declarative timeline-based video editing — single ffmpeg filter_complex per render, no frame pull into Python
Project description
video-arrange
Declarative timeline-based video editing — single ffmpeg invocation per render, no frame pull into Python.
Built from the CineForge and MusicVideoCreator pipelines at Trollfabriken AITrix AB, where hand-written
filter_complex strings became unmaintainable past three clips. The library assembles a full filter graph
from a declarative timeline and calls ffmpeg exactly once per render. Renders a 2-minute, 4-clip project
in ~25s — the same wall time as raw ffmpeg, because that is what runs underneath.
What it solves
| Previous problem | Solution |
|---|---|
| MoviePy pulls every frame into numpy through PIL — 4+ minutes for a 2-minute clip | Single ffmpeg invocation per render; no frame data enters Python; ~25s for the same job |
| ffmpeg filter_complex strings unmaintainable past 3 clips with overlays | Declarative timeline.add(clip, track="A", at=5.0) API builds the graph for you |
| ffmpeg-python operates at the filter level — you still write trim/concat/overlay by hand | Higher-level operations: pip(), split_screen(), concat(transition="wipeleft") |
| Concat with mismatched fps silently produces broken output | Auto-inserts fps= filter to normalize before concat |
| Text overlays with Swedish characters render as boxes | drawtext configured with text_shaping=1; tested for Swedish |
| Picture-in-picture inset crops the main image | Correct scale → overlay ordering with verified geometry |
| Can't reproduce a render exactly later | export_command() returns the full ffmpeg argv — log it in your pipeline |
Installation
pip install video-arrange
With optional extras:
pip install "video-arrange[audio]" # audio-arrange for mixed audio tracks
pip install "video-arrange[overlay]" # web-overlay for HTML/CSS graphics compositing
pip install "video-arrange[still]" # still-motion for Ken Burns and pan/zoom clips
pip install "video-arrange[progress]" # tqdm progress bar during renders
pip install "video-arrange[all]" # everything above
Runtime requirement: ffmpeg must be on PATH.
- macOS:
brew install ffmpeg - Linux:
apt install ffmpeg - Windows:
winget install Gyan.FFmpeg
Quick start
from video_arrange import Timeline, Clip, ColorClip, RenderConfig
# Build a timeline — nothing is rendered until .render() is called
tl = Timeline(width=1920, height=1080, fps=30)
# Trim the first 10 seconds of an interview
interview = Clip("interview.mp4")
intro = tl.trim(interview, start=0.0, end=10.0)
# Concat four clips with a wipe transition between each
clips = [
intro,
Clip("broll_a.mp4"),
Clip("broll_b.mp4"),
Clip("outro.mp4"),
]
tl.concat(clips, track="main", at=0.0, transition="wipeleft", transition_duration=0.5)
# Add a lower-third text overlay at 2s, visible for 5s
tl.text(
"Trollfabriken AITrix AB",
track="titles",
at=2.0,
duration=5.0,
position="bottom-left",
size=48,
color="white",
fade_in=0.3,
fade_out=0.3,
)
# Render — one ffmpeg call, no frame pull into Python
cfg = RenderConfig(crf=20, preset="fast")
tl.render("output.mp4", config=cfg)
The pipeline
┌─────────────────────────────────────────────────────────────────┐
│ video-arrange │
│ │
│ ① Timeline.add() / concat() / pip() / text() / split_screen() │
│ │ │
│ ▼ │
│ ② ffprobe each source file ← ProbeResult cache │
│ │ │
│ ▼ │
│ ③ FilterGraphBuilder.build() │
│ Emits one filter_complex string with all trim/scale/ │
│ overlay/xfade/drawtext/fps-normalize nodes │
│ │ │
│ ▼ │
│ ④ ffmpeg_runner.run_ffmpeg() │
│ Exactly one subprocess call — ffmpeg reads sources, │
│ executes the graph, and writes the output file │
│ │ │
│ ▼ │
│ ⑤ output.mp4 │
└─────────────────────────────────────────────────────────────────┘
Configuration
from video_arrange import RenderConfig
cfg = RenderConfig(
width=1920, # canvas width in pixels
height=1080, # canvas height in pixels
fps=30, # output frame rate
video_codec="libx264",
pixel_format="yuv420p",
crf=20, # lower = higher quality; 18–28 is typical
preset="medium", # ffmpeg preset: ultrafast … veryslow
audio_codec="aac",
audio_bitrate="192k",
audio_sample_rate=48000,
hwaccel=None, # e.g. "videotoolbox" on Apple Silicon
ffmpeg_binary="ffmpeg",
extra_input_args=[],
extra_output_args=[],
keep_intermediate=False,
verbose_ffmpeg=False,
)
| Field | Default | Notes |
|---|---|---|
width |
1920 |
Canvas width in pixels |
height |
1080 |
Canvas height in pixels |
fps |
30 |
Output frame rate |
video_codec |
"libx264" |
ffmpeg video encoder |
pixel_format |
"yuv420p" |
Required for H.264 compatibility |
crf |
20 |
Constant rate factor; lower = larger file |
preset |
"medium" |
Encoding speed/compression trade-off |
audio_codec |
"aac" |
ffmpeg audio encoder |
audio_bitrate |
"192k" |
Audio bitrate |
audio_sample_rate |
48000 |
Output audio sample rate |
hwaccel |
None |
Hardware acceleration name (optional) |
ffmpeg_binary |
"ffmpeg" |
Path to ffmpeg binary |
extra_input_args |
[] |
Extra flags injected before -i |
extra_output_args |
[] |
Extra flags appended to output args |
keep_intermediate |
False |
Keep temp files after render |
verbose_ffmpeg |
False |
Pass ffmpeg stderr through to stdout |
Manifest format
Projects can be driven from a TOML manifest file and rendered via the CLI's
render subcommand.
[config]
width = 1920
height = 1080
fps = 30
video_codec = "libx264"
crf = 20
[[clips]]
source = "interview.mp4"
track = "main"
at = 0.0
[[clips]]
source = "logo.png"
type = "image"
duration = 5.0
track = "overlay"
at = 2.0
position = "top-right"
[[clips]]
source = "background"
type = "color"
color = "black"
duration = 30.0
track = "bg"
at = 0.0
Inspecting what runs
Log the ffmpeg command before every render. That is the reproducibility contract.
from video_arrange import Timeline, Clip, RenderConfig
import logging
logging.basicConfig(level=logging.INFO)
tl = Timeline()
tl.add(Clip("clip_a.mp4"), track="main", at=0.0)
tl.add(Clip("clip_b.mp4"), track="main", at=10.0)
cfg = RenderConfig(crf=18)
# Inspect the filter_complex string without running ffmpeg
graph = tl.export_filter_graph(config=cfg)
print(graph)
# Inspect the full argv — log this in your CI pipeline
argv = tl.export_command(output="output.mp4", config=cfg)
print(" ".join(argv))
# Then render
tl.render("output.mp4", config=cfg)
export_command() returns a plain list[str] — pass it to subprocess.run()
directly, write it to a shell script, or store it alongside the output file.
CLI
# Concatenate three clips with a crossfade wipe between each
video-arrange concat a.mp4 b.mp4 c.mp4 --transition wipeleft --output out.mp4
# Trim a clip to seconds 5–30
video-arrange trim interview.mp4 --start 5 --end 30 --output trimmed.mp4
# Picture-in-picture: webcam inset at 25% in the top-right corner
video-arrange pip screen.mp4 webcam.mp4 --position top-right --inset-size 0.25 --output pip.mp4
# Side-by-side split screen
video-arrange split-screen cam_a.mp4 cam_b.mp4 --layout horizontal --output split.mp4
# Render a TOML manifest (dry-run prints the ffmpeg command)
video-arrange render --manifest project.toml --output final.mp4 --dry-run
Package structure
src/video_arrange/
├── __init__.py ← public re-exports, __version__, ffmpeg availability check
├── cli.py ← argparse CLI: concat / trim / pip / split-screen / render
├── clip.py ← Clip, ImageClip, ColorClip — file references, never decoded
├── config.py ← RenderConfig pydantic model
├── exceptions.py ← FilterGraphError, ProbeError
├── ffmpeg_runner.py ← subprocess wrapper; single call per render
├── manifest.py ← TOML manifest loader → Timeline + RenderConfig
├── models.py ← Event, Track, Transition pydantic models
├── probe.py ← ffprobe wrapper → ProbeResult; result cached per path
├── timeline.py ← Timeline: add / concat / pip / split_screen / text / render
├── transitions.py ← TRANSITIONS registry (xfade names)
├── utils.py ← resolve_position, resolve_size, color_to_ffmpeg, safe_path
└── filter_graph/
├── __init__.py
├── builder.py ← FilterGraphBuilder: builds filter_complex argv
├── nodes.py ← filter node primitives (trim, scale, overlay, xfade, …)
└── audio.py ← audio merge and amix node helpers
© Trollfabriken AITrix AB — MIT licensed
Project details
Release history Release notifications | RSS feed
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 video_arrange-0.1.0.tar.gz.
File metadata
- Download URL: video_arrange-0.1.0.tar.gz
- Upload date:
- Size: 30.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39a2600184b950d1cec7da31f27a0c3cf0e1282d53f20caa1aa2974bdd373dbc
|
|
| MD5 |
7d8f8f8a0e03326dec34eb516f30fc2b
|
|
| BLAKE2b-256 |
cd10c4f01b0e6e47dae97202846520eef80bef1e81e1ac518db1c722bf2c593c
|
Provenance
The following attestation bundles were made for video_arrange-0.1.0.tar.gz:
Publisher:
release.yml on tomastimelock/video-arrange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
video_arrange-0.1.0.tar.gz -
Subject digest:
39a2600184b950d1cec7da31f27a0c3cf0e1282d53f20caa1aa2974bdd373dbc - Sigstore transparency entry: 1600212203
- Sigstore integration time:
-
Permalink:
tomastimelock/video-arrange@762b3d10f0110d402bbb5faa4239d0385c535ace -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/tomastimelock
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@762b3d10f0110d402bbb5faa4239d0385c535ace -
Trigger Event:
push
-
Statement type:
File details
Details for the file video_arrange-0.1.0-py3-none-any.whl.
File metadata
- Download URL: video_arrange-0.1.0-py3-none-any.whl
- Upload date:
- Size: 44.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a580b8cd47f4bbc7d683bcf22dab3be8cb63c0e34ecd9058d5201dd94589430
|
|
| MD5 |
e677d6e542c081947868fa5ab1bd68a2
|
|
| BLAKE2b-256 |
7a22af928729e2d76344dcce57245374d26fd9fb9ff55f27b6906fb4ef3e5f9a
|
Provenance
The following attestation bundles were made for video_arrange-0.1.0-py3-none-any.whl:
Publisher:
release.yml on tomastimelock/video-arrange
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
video_arrange-0.1.0-py3-none-any.whl -
Subject digest:
2a580b8cd47f4bbc7d683bcf22dab3be8cb63c0e34ecd9058d5201dd94589430 - Sigstore transparency entry: 1600212300
- Sigstore integration time:
-
Permalink:
tomastimelock/video-arrange@762b3d10f0110d402bbb5faa4239d0385c535ace -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/tomastimelock
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@762b3d10f0110d402bbb5faa4239d0385c535ace -
Trigger Event:
push
-
Statement type: