The streaming engine¶
VideoEdit.run_to_file() is the only way to execute an operation. There is no
operation.apply(video), and operations never run against a Video object. This page
explains what that buys and what it costs.
One execution path, constant memory¶
The engine streams: FFmpeg decode → the operation chain → FFmpeg encode, one frame at a time. Peak memory is roughly a frame plus the encoder's buffers, so an hour-long source costs the same as a ten-second one.
The alternative — the design most editing libraries take — is to load frames into an array and let each operation return a new array. That is friendlier for one-off scripting and catastrophic for the actual use cases here: hour-long sources, LLM-authored plans running on a server, batch jobs. Rather than maintain both paths and have every operation implement two semantics that can silently diverge, videopython keeps one.
The cost is real and worth stating: you cannot apply an effect to a Video you built in
memory. Save it and put the file in a plan. Generated media (TextToVideo,
ImageToVideo) is the usual place this bites — see Assemble a video from AI-generated
media.
Two kinds of operation¶
Every operation compiles into one of two things.
Filters compile to a native FFmpeg filter via to_ffmpeg_filter(ctx). This is
reserved for what FFmpeg does that numpy cannot do, or cannot do as well:
- all transforms —
resize,crop,resample_fps, the duration-changingspeed_changeandfreeze_frame, the transcription-consumingsilence_removal, andface_crop; - the two text-rendering effects —
text_overlay(drawtext) andadd_subtitles(libass).
Per-frame effects are shape-preserving Python over each decoded frame, via
streaming_init + process_frame. Every pixel effect lives here: blur, sharpen,
zoom, film_grain, chromatic_aberration, mirror_flip, vignette, color_adjust,
kaleidoscope, shake, flash, glitch, pixelate, ken_burns, punch_in, and the
image overlays.
Why pixel effects are not FFmpeg filters¶
This looks like the obvious optimization, so it was measured. Compiling pixel effects to
native filters bought at best ~1.1–1.4×, and in some cases (gblur) lost. The gain
that did exist came from skipping the rawvideo round-trip, not from faster math — the
effects are vectorised numpy/cv2 and are not the bottleneck.
So the engine reserves FFmpeg for geometry, timing and text rendering, and keeps the per-frame path for its simplicity and its exact, testable output.
What that means for a segment¶
- A segment whose operations are all filters renders in a single FFmpeg invocation — no rawvideo round-trip, no Python loop.
- A single per-frame effect switches that segment to decode → Python → encode.
Filters ordered before the effect join the decode chain; filters after it join the
encode chain. So
[fade, add_subtitles]streams fine. - Duration-changing transforms fold their predicted metadata through the chain, so later effect windows and the audio track follow the new timeline.
post_operationsrun as a second pass over the assembled program, so any operation — filter, effect or transform — can apply to the whole concatenated timeline.
Shapes that cannot stream¶
Streamability is decided structurally, from operation classes, their order, and the plan
shape — never from the media. Four shapes have no streaming strategy and are rejected with
STREAMING_UNSUPPORTED errors before any decode:
| Shape | Fix |
|---|---|
| A per-frame effect ordered after encode-stage filters | Move the effect earlier |
| A context-requiring op after a duration-changing transform | Move the op before the transform |
face_crop behind per-frame effects |
Put face_crop first |
| A time-based-context post-op on a multi-segment plan | Move the op into a segment — source-absolute context cannot re-base onto a concat |
Because the decision needs no media, edit.streamability() is safe to call as an
admission gate before a worker downloads anything:
report = edit.streamability()
report.streamable # bool
report.unstreamable # offending ops, with reasons and reorder hints
report.errors() # the same as structured PlanErrors
edit.check(meta) reports the same errors after the ordinary validity errors, and
run_to_file() raises them before it opens the source.
Context data on a streaming path¶
Some operations need input a JSON plan cannot carry — a whole Transcription, for
instance. Those declare requires: ClassVar[tuple[str, ...]], and the runner pulls the
matching keys out of run_to_file(context=...).
Time-based values are re-based onto the segment's local timeline before delivery: a transcription with source-absolute timestamps, used in a segment starting at 30 s, arrives shifted so that the segment starts at zero. Without that, every context-consuming operation would have to know its own offset, and every caller would have to pre-shift by hand.
The resolved values reach the operation through streaming_init (per-frame effects) or
FilterCtx.context (filter-compiled ops), and through predict_metadata during
validation.
Subtitles specifically¶
add_subtitles does not draw text per frame. At plan-compile time the transcription is
compiled to an ASS document, and FFmpeg's subtitles= filter burns it in with libass —
native speed, and long cues wrap inside the box rather than overflowing the frame. This
requires an FFmpeg built with libass, which every mainstream package provides.