Skip to main content

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

video_arrange-0.1.0.tar.gz (30.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

video_arrange-0.1.0-py3-none-any.whl (44.8 kB view details)

Uploaded Python 3

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

Hashes for video_arrange-0.1.0.tar.gz
Algorithm Hash digest
SHA256 39a2600184b950d1cec7da31f27a0c3cf0e1282d53f20caa1aa2974bdd373dbc
MD5 7d8f8f8a0e03326dec34eb516f30fc2b
BLAKE2b-256 cd10c4f01b0e6e47dae97202846520eef80bef1e81e1ac518db1c722bf2c593c

See more details on using hashes here.

Provenance

The following attestation bundles were made for video_arrange-0.1.0.tar.gz:

Publisher: release.yml on tomastimelock/video-arrange

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for video_arrange-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2a580b8cd47f4bbc7d683bcf22dab3be8cb63c0e34ecd9058d5201dd94589430
MD5 e677d6e542c081947868fa5ab1bd68a2
BLAKE2b-256 7a22af928729e2d76344dcce57245374d26fd9fb9ff55f27b6906fb4ef3e5f9a

See more details on using hashes here.

Provenance

The following attestation bundles were made for video_arrange-0.1.0-py3-none-any.whl:

Publisher: release.yml on tomastimelock/video-arrange

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page