Skip to main content

core-flux

A fast, layer-based video editing library for Python, built on native FFmpeg filtergraphs. No required dependencies — it drives the ffmpeg binary directly.

Every edit you chain appends a node to a filter graph. Nothing runs until render(), which compiles the whole timeline into one FFmpeg process. Frames never cross into Python, so a composite runs at FFmpeg's speed instead of Python's — and when you do need the pixels, there's an escape hatch that's still faster than the alternatives.

from core_flux import VideoLayer, AudioLayer, Composition

Composition(
    layers=[
        VideoLayer("gameplay.mp4").resize(1920, 1080).fade_out(),
        VideoLayer("facecam.mp4").resize(400, 300).set_position(50, 50).mute(),
    ],
    audio_tracks=[AudioLayer("lofi.mp3").with_volume_scaled_to(0.2)],
).render("edit.mp4")

3–6x faster than MoviePy, or 13–21x with hardware encoding — and 3.6x faster even on custom per-frame NumPy effects. Benchmarks below, reproducible on your machine.

Renamed in 0.4.0: the import is core_flux, matching the package name. import fastvideo still works with a DeprecationWarning until 1.0.


Installation

pip install core-flux            # zero dependencies
pip install core-flux[all]       # + Pillow (text) + NumPy (raw frames)

FFmpeg must be installed separately and on your PATH:

Platform Command
macOS brew install ffmpeg
Debian/Ubuntu sudo apt install ffmpeg
Windows winget install ffmpeg

Requirements: Python 3.8+, FFmpeg 4.0+.

Optional extras. [text] installs Pillow, used only when your FFmpeg lacks drawtext/subtitles. [frames] installs NumPy for raw frame access. Neither is needed for normal editing.


Core concepts

Layers stack; the first is the canvas. layers[0] is the background; the rest composite on top in order.

Output length follows the base video layer. Longer music is cut off, shorter is padded with silence. A short overlay disappears when it ends rather than freezing.

Track volumes are absolute. with_volume_scaled_to(0.2) means 20%, however many tracks are mixed.

Nothing runs until render(). Layers are cheap to build and rearrange. get_command() shows the FFmpeg invocation without running it.


Quick start

from core_flux import VideoLayer, AudioLayer, Composition, crossfade, TextLayer

# Composite with music
Composition(
    layers=[
        VideoLayer("gameplay.mp4").resize(1920, 1080).fade_out(duration=1.5),
        VideoLayer("facecam.mp4").resize(400, 300).set_position(50, 50).mute(),
    ],
    audio_tracks=[AudioLayer("lofi.mp3").with_volume_scaled_to(0.2).fade_out()],
).render("edit.mp4")

# Join clips with crossfades
reel = crossfade([
    VideoLayer("intro.mp4"),
    VideoLayer("main.mp4").trim(5, 45),
    VideoLayer("outro.mp4"),
], duration=1.0, transition="fade")
Composition(layers=[reel]).render("reel.mp4")

# Titles, captions, split screens
Composition(layers=[
    VideoLayer("talk.mp4").resize(1280, 720),
    TextLayer("Chapter One", 1280, 720, duration=4, x=80, y=560, size=64),
]).render("titled.mp4")

# Green screen
Composition(layers=[
    VideoLayer("background.mp4").resize(1920, 1080),
    VideoLayer("presenter.mp4").resize(960, 540)
        .chroma_key("green").set_position(480, 270),
]).render("keyed.mp4")

# Thumbnails, GIFs, audio-only
timeline.save_frame("thumb.jpg", t=12.5)
timeline.render("clip.gif", gif_fps=15, gif_width=480)
timeline.render("mix.wav")

API reference

VideoLayer(input_path)

Probed on construction, so a bad path fails immediately. Every method returns self, so calls chain.

Properties: .duration, .width, .height, .fps, .aspect_ratio, .n_frames, .has_audio, .end. All track your edits — after .trim(0, 5).speed(2.0), .duration is 2.5.

Geometry

Method Description
.set_position(x, y) Place the top-left corner. Accepts FFmpeg expressions, so x="20+t*30" animates.
.resize(w, h) · .scale_by(factor) Scale exactly, or by a multiplier.
.crop(x1, y1, w, h) Keep a rectangle.
.rotate(degrees, fill="black") 90/180/270 are lossless; other angles grow the frame.
.flip(axis) Mirror "horizontal" or "vertical".
.margin(size, color, top, bottom, left, right) Add a border.
.even_size() Round up to even dimensions, which H.264 requires.

Appearance

Method Description
.adjust_colors(contrast, brightness, saturation) · .gamma(v) · .multiply_color(f) Colour grading.
.blackwhite() · .invert() · .vignette() Looks.
.blur(radius) · .sharpen(amount) · .supersample(frames) Softening and detail.
.set_opacity(alpha) Semi-transparency for overlays.
.chroma_key(color, similarity, blend) Knock out a background colour.
.scroll(horizontal, vertical) Scroll the picture.
.add_text(...) · .add_subtitles(path, ...) Burn in captions.

Time

Method Description
.trim(start, end) · .subclip(start, end=None) Keep a section.
.cut_out(start, end) Remove a middle section, joining the rest.
.set_duration(d) · .set_end(t) · .set_fps(fps) Trim/pad to length; resample.
.speed(factor) Constant rate change; audio pitch preserved.
.accel_decel(new_duration, strength) Ease in and out of motion. Drops audio.
.reverse() · .time_symmetrize() Backwards; forwards-then-backwards.
.loop(count) Repeat, via -stream_loop (no frame buffering).
.make_loopable(overlap) Crossfade the ending into the opening.
.freeze(t, duration) · .hold_last_frame(d) Pause mid-clip; hold the final frame.
.set_start(seconds) Delay the layer's entry.
.fade_in(...) · .fade_out(...) Picture and audio together.
.blink(on, off) Flash an overlay on and off.
.slide_in(duration, side) · .slide_out(duration, side) Animate in/out from an edge.

Masks and audio

Method Description
.to_mask() · .with_mask(layer) Greyscale mask; apply one as alpha.
.mask_and(other) · .mask_or(other) Combine masks.
.freeze_region(t, x, y, w, h) Freeze one rectangle, leave the rest live.
.with_volume_scaled_to(f) · .mute() · .set_audio(track) Sound.
.copy() · .to_image_layer(t, duration) Branch a layer; freeze a frame into a still.

AudioLayer(input_path)

.with_volume_scaled_to(), .mute(), .normalize(target=-16.0), .with_stereo_volume(left, right), .delay(seconds, decay), .trim(), .subclip(), .set_duration(), .speed(), .reverse(), .loop(), .set_start(), .fade_in(), .fade_out(), .max_volume(), plus .duration and .end.

AudioLayer("podcast.wav").trim(30, 90).normalize().fade_in().fade_out()

.normalize() targets LUFS (EBU R128): -16 for podcasts and web, -14 for music streaming.


Other layer types

ImageLayer("logo.png", duration=3).set_position(20, 20).set_opacity(0.7)
ColorLayer(1920, 1080, duration=10, color="#101014")
TextLayer("Hello", 1280, 720, duration=4, size=64, box=True)
ImageSequenceLayer("frames/%04d.png", fps=24)   # or "frames/*.png"

Combining clips

Function Description
concatenate(layers, width, height, fps, audio=True) Join end to end with hard cuts.
crossfade(layers, duration, transition, ...) Join with an overlap. 44 styles in core_flux.TRANSITIONS.
clips_array(rows, width, height) Arrange in a grid — split screens, contact sheets.
concatenate_audio(tracks) Join audio tracks end to end.

All return an ordinary layer, so the result keeps chaining.

from core_flux import clips_array, crossfade, TRANSITIONS

crossfade(clips, duration=0.75, transition="circleopen")
clips_array([[a, b], [c, d]], width=960, height=540)

Composition(layers=None, audio_tracks=None)

Member Description
.add_layer(l) · .add_audio(t) Chainable.
.duration Expected output length.
.get_command(path, **kw) The FFmpeg command that would run.
.render(path, ...) Compile and write.
.save_frame(path, t) · .save_frames(pattern, fps) One image; a numbered sequence.
.preview(**kw) Play through ffplay instead of writing a file.
.iter_frames(...) Stream composed frames into Python.

render(output_path, format_type=None, quiet=False, verbose=False, overwrite=True, duration=None, hardware=False, **encoder_options)

Argument Description
format_type 'video', 'gif', 'audio'. Inferred from the extension.
hardware Use a hardware H.264 encoder where available.
duration Hard-cap the output length.
gif_fps, gif_width GIF only; default 15 and the source width.
**encoder_options Output options: crf=18, preset="slow", r=60.

MP4-family containers default to H.264 + AAC, yuv420p, +faststart. Others use FFmpeg's defaults, so .webm correctly gets VP9/Opus.


Raw frames — the escape hatch

Everything above compiles to filters and never touches Python. When you need actual pixels, these decode to raw frames and back:

# Read-only: measure, analyse, extract
for frame in VideoLayer("clip.mp4").resize(640, 360).iter_frames():
    ...                                  # (360, 640, 3) writable uint8 array

frame = VideoLayer("clip.mp4").resize(640, 360).get_frame(t=2.0)

# Transform: any effect you can write in NumPy
def glow(frame):
    frame[:, :, 0] = 255 - frame[:, :, 0]
    return frame

edited = VideoLayer("clip.mp4").resize(1280, 720).apply_frame_function(glow)
Composition(layers=[edited.fade_out()]).render("out.mp4")   # keeps chaining

# Generate video from scratch
from core_flux import FrameWriter
with FrameWriter("generated.mp4", 640, 360, fps=30) as writer:
    for frame in my_frames:
        writer.write(frame)

iter_frames() streams, so memory stays flat on any clip length. apply_frame_function() has to materialise to a scratch file — a filtergraph cannot contain Python — but returns a normal layer you can keep editing, and carries the original audio across. Pass output_path= to keep the result.

Needs NumPy (pip install core-flux[frames]); without it, pass as_numpy=False to work with bytes.


Errors

Everything raised on purpose inherits from CoreFluxError:

Exception Raised when
FFmpegNotFoundError ffmpeg/ffprobe not on PATH.
MediaNotFoundError An input file does not exist.
UnsupportedMediaError Unreadable, or missing the needed stream.
FilterUnavailableError Your FFmpeg build lacks a required filter.
RenderError FFmpeg exited non-zero. Carries .command and .stderr.
try:
    timeline.render("out.mp4")
except RenderError as e:
    print(e.stderr)   # what FFmpeg actually said
    print(e.command)  # the exact command, to re-run by hand

Performance

Apple M1 (8-core, 16 GB, native arm64, Python 3.10), three runs each after a warmup, on a 15-second 1080p clip at a realistic ~8 Mbps.

Workload                                  ffmpeg  core-flux  +hardware   moviepy   speedup
--------------------------------------------------------------------------------------------
transcode (1080p -> 720p, H.264/AAC)       5.97s     6.16s      1.43s     18.86s   3.1x / 13.2x
composite (overlay + audio mix + fade)     5.84s     5.78s      1.52s     32.47s   5.6x / 21.3x
transition (3 clips, crossfades)           3.73s     3.93s      1.47s     22.41s   5.7x / 15.3x
frames (custom per-pixel NumPy effect)         -     4.68s          -     16.68s   3.6x

Reading these honestly:

  • core-flux tracks raw FFmpeg within a few percent. That's the point: it adds a graph builder, not a processing layer.
  • The gap widens with compositing (3.1x → 5.6x) because MoviePy composites frames in Python. More layers, wider gap.
  • The frames row is the interesting one. Neither engine can express a custom NumPy effect as a filter, so both decode to Python — yet core-flux is still 3.6x faster, because the only per-frame Python is your function.
  • Ratios depend on your footage and machine load. Encoding is the floor for everyone; on easily-compressed video the same benchmark shows 12x+ without hardware, which flatters core-flux. Treat 3–6x as the realistic software range; I've measured the transcode row anywhere from 3.1x to 3.8x across runs.
  • Hardware encoding isn't a quality compromise here. The VideoToolbox default (q:v=50) was calibrated by SSIM against the source: 0.959 vs libx264 -crf 23's 0.961, in a 20% smaller file.
pip install moviepy
python benchmark/benchmark.py     # generates its own inputs

Coming from MoviePy

MoviePy core-flux
VideoFileClip(p) VideoLayer(p)
clip.with_effects([Resize(...), FadeOut(1)]) layer.resize(...).fade_out(1)
CompositeVideoClip([a, b]) Composition(layers=[a, b])
concatenate_videoclips([...]) concatenate([...]) or crossfade([...])
clip.with_position((x, y)) layer.set_position(x, y)
clip.subclipped(a, b) layer.subclip(a, b)
clip.image_transform(fn) layer.apply_frame_function(fn)
clip.iter_frames() layer.iter_frames()
clip.write_videofile(p) composition.render(p)

The main structural difference: MoviePy clips are self-contained and you compose at the end; core-flux layers are positioned on a Composition that owns the timeline. And core-flux is lazy — nothing decodes until render().

Still MoviePy-only: rich text layout (TextClip styling), keyframed effect parameters (positions animate via expressions, but effect intensities don't), motion tracking, and its Painting/HeadBlur novelty effects.


Development

git clone https://github.com/faarisaahmed/core-flux
cd core-flux
pip install -e ".[dev]"
pytest

194 tests. The suite generates its own media with FFmpeg's lavfi sources, so no fixture files are needed. Tests asserting durations, sizes, volumes or pixels render real files; tests asserting graph shape inspect get_command().


License

MIT

Download files

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

Source Distribution

core_flux-0.6.0.tar.gz (49.9 kB view details)

Uploaded Source

Built Distribution

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

core_flux-0.6.0-py3-none-any.whl (39.0 kB view details)

Uploaded Python 3

File details

Details for the file core_flux-0.6.0.tar.gz.

File metadata

  • Download URL: core_flux-0.6.0.tar.gz
  • Upload date:
  • Size: 49.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.13

File hashes

Hashes for core_flux-0.6.0.tar.gz
Algorithm Hash digest
SHA256 fb3084af811ba0a03ecad68f7af7f01ba8fc3f292aef09889dab6018800701f8
MD5 c1f873e00ff1c6c94563fa7d3d483301
BLAKE2b-256 088bc42b7a8167257d54e5f1868ff46b632d0bc66033e36efd69407cc5ecc0f8

See more details on using hashes here.

File details

Details for the file core_flux-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: core_flux-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 39.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.10.13

File hashes

Hashes for core_flux-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1598f7c1f5e19a3423ec976b0cdf050ce8cd19bab87c296601ceb01409a11edf
MD5 acfc8b52cebfbab5c9bd237ea26f462e
BLAKE2b-256 22dfd12221ffb6626331e5541ffed78d8864e4a87e26d113691eb157aae8339a

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 files

0.4.0

2 files

0.3.4

2 files

0.3.3

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page