Metadata-Version: 2.4
Name: video-arrange
Version: 0.1.0
Summary: Declarative timeline-based video editing — single ffmpeg filter_complex per render, no frame pull into Python
Project-URL: Homepage, https://github.com/tomastimelock/video-arrange
Project-URL: Repository, https://github.com/tomastimelock/video-arrange
Project-URL: Issues, https://github.com/tomastimelock/video-arrange/issues
Author-email: Trollfabriken AITrix AB <dev@trollfabriken.se>
License-Expression: MIT
License-File: LICENSE
Keywords: editing,ffmpeg,filter_complex,overlay,splitscreen,timeline,video
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Multimedia :: Video :: Conversion
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.5
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: all
Requires-Dist: audio-arrange>=0.1; extra == 'all'
Requires-Dist: still-motion>=0.1; extra == 'all'
Requires-Dist: tqdm>=4.65; extra == 'all'
Requires-Dist: web-overlay>=0.1; extra == 'all'
Provides-Extra: audio
Requires-Dist: audio-arrange>=0.1; extra == 'audio'
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest-cov>=4; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: overlay
Requires-Dist: web-overlay>=0.1; extra == 'overlay'
Provides-Extra: progress
Requires-Dist: tqdm>=4.65; extra == 'progress'
Provides-Extra: still
Requires-Dist: still-motion>=0.1; extra == 'still'
Description-Content-Type: text/markdown

# 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

```python
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

```python
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.

```toml
[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.

```python
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

```bash
# 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
