Operations
Every editing primitive in videopython is an Operation subclass — a
Pydantic BaseModel whose fields ARE the JSON wire format. Subclasses
auto-register via __pydantic_init_subclass__, so importing
videopython.editing (or videopython.ai) populates the registry. The
registry is what VideoEdit.json_schema() uses to build the
discriminated-union schema for LLM-driven plan generation.
Subclass Contract
from typing import ClassVar, Literal
import numpy as np
from pydantic import Field
from videopython.editing import Operation, OpCategory, FilterCtx
from videopython.base.video import VideoMetadata
class Resize(Operation):
"""Resize the video.
Args:
width: Target width in pixels.
height: Target height in pixels.
"""
op: Literal["resize"] = "resize" # discriminator + registry key
category: ClassVar[OpCategory] = OpCategory.TRANSFORM
width: int | None = Field(None, gt=0)
height: int | None = Field(None, gt=0)
def predict_metadata(self, meta: VideoMetadata) -> VideoMetadata: ...
def to_ffmpeg_filter(self, ctx: FilterCtx) -> str | None: ... # filter-compiled transforms
There is no apply(). Operations execute only through VideoEdit's
streaming engine (run_to_file); they never run against a
Video directly. A subclass implements:
predict_metadata(self, meta) -> VideoMetadata— predict the outputVideoMetadataand fail fast on plans that would crash at run time. Defaults to identity (override on the baseOperation; onEffectit is identity, since effects preserve shape and frame count).- either
to_ffmpeg_filter(self, ctx)(andto_ffmpeg_audio_filterfor a duration-changing transform's audio twin) — for ops compiled into the ffmpeg filter chain — orstreaming_init(self, total_frames, fps, width, height, **context)+process_frame(self, frame, frame_index)— for per-frame Python effects.
Notes:
opis a one-valueLiteralfield (not aClassVar). It flows into the JSON wire as the discriminator and is also the registry key.categoryisOpCategory.TRANSFORM,OpCategory.EFFECT, orOpCategory.SPECIAL.- Every registered op is streamable, decided structurally by
op.streams()(there is nostreamableflag): a transform streams iff it implementsto_ffmpeg_filter; an effect iff it implementsprocess_frame+streaming_init(a frame effect) orto_ffmpeg_filter+compiles_to_filter(a filter effect). internal_only: ClassVar[bool] = False, whenTrue, keeps an op OUT of the registry — constructed directly by the engine, never a chain op.cut/cut_framesuse it, since trimming is the segment's ownstart/end.- Context-dependent ops declare
requires: ClassVar[tuple[str, ...]] = ("transcription",). The runner picks the matching keys out of thecontextdict passed torun_to_file(..., context=...), re-bases any time-based values onto the segment's local timeline, and threads them into the effect'sstreaming_init(andpredict_metadata) as keyword arguments — or onto theFilterCtx.contextfor a filter-compiled op.
Effects
Effect(Operation) adds a window: TimeRange | None field and preserves
shape and frame count (so its predict_metadata is identity). The
streaming engine resolves window against the segment timeline, leaving
frames outside the window untouched. A frame effect implements the
streaming_init / process_frame pair:
class Glitch(Effect): # a frame effect: no faithful ffmpeg form
op: Literal["glitch"] = "glitch"
# ... fields ...
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray: ...
The window field on the wire:
The two text-rendering effects instead compile to a native filter (no per-frame
Python) by setting the compiles_to_filter property and implementing
to_ffmpeg_filter: add_subtitles (libass subtitles=) and text_overlay
(drawtext). Audio-coupled effects (Fade, VolumeAdjust) add
to_ffmpeg_audio_filter for their audio twin while their video runs per-frame.
Every other (pixel) effect runs vectorised numpy/cv2 in process_frame:
benchmarks showed compiling them to ffmpeg filters bought at best ~1.1–1.4x (from
skipping the rawvideo round-trip, not faster compute) and sometimes lost, so the
engine reserves filters for geometry/timing transforms and text rendering.
Registry API
from videopython.editing import Operation
# Snapshot of {op_id: subclass} for every registered operation:
Operation.registry()
# LLM-safe subset: only ops with llm_exposed=True (omits server-only ops):
Operation.llm_registry()
# Look up by op_id (raises KeyError if unknown):
cls = Operation.get("resize")
# Discriminated-union JSON Schema over the LLM-exposed ops:
schema = Operation.json_schema()
# ...or over every registered op (worker / from_dict path):
full = Operation.json_schema(include_server_only=True)
AI operations register lazily, so call import videopython.ai before
inspecting the registry if you need face_crop and friends.
LLM-exposed vs server-only ops
Every Operation carries llm_exposed: ClassVar[bool] = True. Set it to
False for ops the model must never emit — typically ops that need a
server-resolved source path (image_overlay, full_image_overlay).
Operation.llm_registry() and the default Operation.json_schema() /
VideoEdit.json_schema() cover only llm_exposed ops, while
Operation.registry() and from_dict still see all ops so a stored
plan continues to execute.
The same idea applies at the field level: a field declared with
Field(json_schema_extra={"llm_hidden": True}) is a valid wire field (it
still parses and runs) but is dropped from the LLM-facing schema. This hides
advanced overrides the model shouldn't fill in — e.g. the raw font_filename
path on text_overlay/add_subtitles, whose LLM-facing counterpart is the
font name enum. The default Operation.json_schema() and
cls.llm_json_schema() (below) strip these; cls.model_json_schema() keeps
them.
Discovering Operations
from videopython.editing import Operation, OpCategory
for op_id, cls in Operation.registry().items():
print(f"{op_id}: {cls.__doc__.splitlines()[0]}")
transforms = {k: v for k, v in Operation.registry().items()
if v.category is OpCategory.TRANSFORM}
Per-Operation JSON Schema
Every subclass exposes cls.model_json_schema() (standard Pydantic),
returning the JSON Schema for that specific op's fields. For an LLM-facing
single-op schema, use cls.llm_json_schema() — identical but with
llm_hidden fields stripped:
from videopython.editing import Operation
cls = Operation.get("blur_effect")
schema = cls.model_json_schema() # full (all fields)
llm_schema = cls.llm_json_schema() # LLM-facing (llm_hidden dropped)
# {
# "properties": {
# "op": {"const": "blur_effect", ...},
# "mode": {"enum": ["constant", "ascending", "descending"], ...},
# "iterations": {"type": "integer", "minimum": 1, ...},
# "window": {"anyOf": [{"$ref": "..."}, {"type": "null"}], ...},
# ...
# },
# ...
# }
Operation.json_schema() is the union over the LLM-exposed ops (pass
include_server_only=True for all of them), and that's the schema
VideoEdit.json_schema() embeds for the operations field.
Registered Operations
Base (no AI dependencies)
cut/cut_frames are internal-only: the engine trims each segment via its
start/end, so they are not chain ops and do not appear here. Every registered
op below is streamable (it compiles to an ffmpeg filter or is a per-frame effect).
| ID | Class | Category | Streamable |
|---|---|---|---|
resize |
Resize |
transform | yes |
resample_fps |
ResampleFPS |
transform | yes |
crop |
Crop |
transform | yes |
speed_change |
SpeedChange |
transform | yes — compiles to setpts + CFR resample; audio time-stretched in sync |
freeze_frame |
FreezeFrame |
transform | yes — compiles to a loop-based chain; silence inserted in the audio |
silence_removal |
SilenceRemoval |
transform | yes — select keep-window cut (requires transcription context) |
blur_effect |
Blur |
effect | yes |
zoom_effect |
Zoom |
effect | yes |
color_adjust |
ColorGrading |
effect | yes |
vignette |
Vignette |
effect | yes |
ken_burns |
KenBurns |
effect | yes |
full_image_overlay † |
FullImageOverlay |
effect | yes |
image_overlay † |
ImageOverlay |
effect | yes |
fade |
Fade |
effect | yes |
volume_adjust |
VolumeAdjust |
effect | yes |
text_overlay |
TextOverlay |
effect | yes |
add_subtitles |
TranscriptionOverlay |
effect | yes — compiles to a libass subtitles= filter (requires transcription context) |
shake |
Shake |
effect | yes |
punch_in |
PunchIn |
effect | yes |
flash |
Flash |
effect | yes |
chromatic_aberration |
ChromaticAberration |
effect | yes |
glitch |
Glitch |
effect | yes |
film_grain |
FilmGrain |
effect | yes |
sharpen |
Sharpen |
effect | yes |
pixelate |
Pixelate |
effect | yes |
mirror_flip |
MirrorFlip |
effect | yes |
kaleidoscope |
Kaleidoscope |
effect | yes |
† Server-only (llm_exposed=False): excluded from Operation.llm_registry()
and the default LLM-facing schema because they need a server-resolved
source path. Still executable via from_dict / Operation.registry().
AI (require import videopython.ai)
| ID | Class | Category | Streamable |
|---|---|---|---|
face_crop |
FaceTrackingCrop |
transform | yes — compile-time detection pass drives a per-frame crop track |
object_detection_overlay |
ObjectDetectionOverlay |
effect | yes — per-frame box overlay; D-FINE detection on a detection_interval cadence; bounded memory, not bounded compute |
API Reference
Operation
Operation
Bases: BaseModel
Pydantic base for every editing primitive.
Concrete subclasses MUST declare an op field with a single-value
Literal[str] annotation; that value is the discriminator on the JSON
wire and the registry key. Subclasses may override the category and
requires ClassVars.
predict_metadata defaults to identity; to_ffmpeg_filter defaults to
None (no filter compilation).
Source code in src/videopython/editing/operation.py
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 | |
compiles_from_source
class-attribute
Whether the op's filter compile decodes the source itself (face_crop's detection pass). Such ops cannot sit at the encode stage -- the frames behind per-frame Python effects are not reproducible at compile time -- so both the plan builder and the streamability report reject them as UNSTREAMABLE there.
changes_duration
class-attribute
Whether the op's output duration differs from its input (speed, freeze).
The streaming plan builder folds predict_metadata through the chain
either way; this flag additionally gates time-based context: a
context-consuming op scheduled after a duration-changing transform would
receive timestamps on the wrong timeline, so such plans are rejected as
UNSTREAMABLE until context re-mapping exists. The streamability report
mirrors the same rule.
internal_only
class-attribute
Whether this op is engine-internal and must NOT be a chain op.
CutSeconds/CutFrames trim a segment, but trimming is the segment's own
start/end mechanism -- the engine constructs them directly. They have
no ffmpeg filter and no process_frame, so this flag keeps them OUT of the
registry: they cannot appear in a plan's operations list or the LLM
schema, while direct construction (CutSeconds(start=..., end=...)) still
works. Default False (a normal chain op).
time_fields
class-attribute
Time-valued (seconds) fields :meth:VideoEdit.repair may clamp into range.
Declaring a :class:BoundedTimeField here lets repair clamp an
out-of-range timestamp (e.g. freeze_frame.timestamp past the clip end)
without per-op special-casing -- the repair pass reads the declaration,
clamps to [0, bound], and records a :class:PlanRepair. Empty by
default; ops with no time-valued params declare nothing.
registry
classmethod
llm_registry
classmethod
Snapshot of {op_id: subclass} for LLM-exposed Operations only.
A subset of :meth:registry filtered to subclasses with
llm_exposed True. Server-only ops (e.g. those needing a
server-resolved source path) are excluded so they never leak into
the LLM-facing schema.
Source code in src/videopython/editing/operation.py
get
classmethod
Look up the Operation subclass for op_id.
Source code in src/videopython/editing/operation.py
json_schema
classmethod
Discriminated-union JSON schema over registered Operations.
op is the discriminator tag. This is the LLM-facing schema for
validating a single operation payload. By default the union covers only
LLM-exposed ops (:meth:llm_registry); pass include_server_only=True
to build the union from the full :meth:registry. Fields marked
llm_hidden (advanced overrides like raw font paths) are stripped.
With strict=True the schema is rewritten for use as a provider
structured-output grammar (OpenAI/OpenRouter json_schema strict
mode): every object is closed (additionalProperties: false), every
property is listed in required with its optionality kept exactly as
Pydantic emitted it (an Optional field keeps its nullable branch; a
defaulted non-Optional field -- including the op discriminator --
stays required and non-nullable), and the discriminated union is
expressed as a plain anyOf of closed variants (discriminator,
default, custom format, and $schema -- all unsupported or moot
in strict mode -- are dropped). Numeric constraints
(minimum/maximum/exclusiveMinimum) are preserved, so an
entire class of bound violations becomes impossible at decode time.
Note: the strict result is a root-level anyOf union -- an embeddable
schema fragment, not a submittable strict root (providers require the root
to be a closed object). It is consumed inside
:meth:VideoEdit.json_schema(strict=True) <VideoEdit.json_schema>, which
is a submittable object root; use that to constrain a whole plan.
Source code in src/videopython/editing/operation.py
llm_json_schema
classmethod
Per-op JSON schema with llm_hidden fields removed.
Like cls.model_json_schema() but drops advanced / non-LLM fields
(e.g. raw font paths) so a single op can be exposed to an LLM directly
without leaking a field the model shouldn't fill in.
Source code in src/videopython/editing/operation.py
predict_metadata
Predict output metadata from input metadata. Default: identity.
Run during VideoEdit.validate()'s dry-run, before any frames are
decoded. Beyond predicting shape, this is the fail-fast gate, and it
has one contract: reject exactly the plans that would otherwise crash
or do unrecoverable / expensive work in run_to_file();
anything run_to_file() can absorb by graceful degradation is NOT rejected.
TranscriptionOverlay rejects un-fittable subtitles (they used to
crash mid-render); TextOverlay/ImageOverlay do not reject
off-frame geometry (it clips to a valid no-op). Keep the check
metadata-cheap -- no frame decode.
Duration bounds checks use the shared
:data:videopython.editing.transforms.DURATION_EPS tolerance: a value
is rejected only when it exceeds the limit by more than DURATION_EPS
seconds, so sub-millisecond float drift at an exact boundary passes
consistently across the editing layer.
Source code in src/videopython/editing/operation.py
to_ffmpeg_filter
Compile to an ffmpeg -vf filter expression, or None for no filter compilation.
Streamable transforms override this. Effects use process_frame
instead -- they do not go through ffmpeg filters.
Source code in src/videopython/editing/operation.py
to_ffmpeg_audio_filter
Compile the op's audio-domain twin to an ffmpeg audio-filter expression.
The audio analogue of :meth:to_ffmpeg_filter: segment audio now
streams through the SAME ffmpeg process as the video (a second
-i source input routed through -filter_complex), so a
duration-changing transform expresses its audio effect as a filter on
that graph instead of mutating an in-memory Audio array
(speed_change -> atempo, freeze_frame -> silence splice,
silence_removal -> aselect keep windows, fade -> afade,
volume_adjust -> volume).
ctx is the SAME :class:FilterCtx the video side builds at this
op's plan position -- ctx.fps/ctx.frame_count are the
already-folded values, ctx.context carries the resolved,
segment-local requires -- so the audio chain stays in lockstep
with the video chain. The returned expression is a comma-joined
single-input/single-output filter sub-chain (e.g. "atempo=2.0");
the plan builder appends it to the segment's labeled audio graph at
the same stage (decode/encode) it appends the video filter. None
means "no audio effect" -- the default, so the builder only emits a
filter for the four audio-affecting ops.
Source code in src/videopython/editing/operation.py
streams
Whether this op streams in O(1) memory at its plan position.
Structural replacement for the former streamable ClassVar: a transform
streams iff it overrides :meth:to_ffmpeg_filter. :class:Effect widens
this (a frame effect streams via process_frame; a filter effect via
compiles_to_filter). The one case structure cannot express -- the
override exists but the filter compiles to None at this position -- is
caught at runtime by the STREAMING_UNSUPPORTED raise in
VideoEdit._compile_streaming_plans.
Source code in src/videopython/editing/operation.py
Effect
Effect
Bases: Operation
Operation that preserves shape and frame count, driven by per-frame streaming.
Subclasses implement the streaming contract -- :meth:process_frame (and
:meth:streaming_init for any precomputed per-stream state) -- which is the
single source of truth for the effect's pixel logic. The streaming engine
in editing/streaming.py drives that contract for bounded-memory
execution, resolving :attr:window against the segment timeline so frames
outside the window pass through untouched.
Effects that compile to a native ffmpeg filter instead set
:attr:compiles_to_filter and implement :meth:to_ffmpeg_filter (and, for
audio-coupled effects like Fade/VolumeAdjust,
:meth:to_ffmpeg_audio_filter) so the window stays coherent across the
decode/encode graph.
Source code in src/videopython/editing/operation.py
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 | |
audio_coupled
class-attribute
Whether the effect mutates audio alongside pixels (afade/volume).
compiles_to_filter
property
Whether this effect joins the decode filter chain instead of scheduling per-frame Python.
When True, the streaming plan builder calls :meth:to_ffmpeg_filter
(with the segment's resolved context on the :class:FilterCtx) and, if
it compiles, appends the result to the vf chain at this op's plan
position -- the Filter class of the streaming contract. Instance-level
rather than a ClassVar because it may depend on field values (e.g.
add_subtitles's renderer). False by default: effects normally
stream via streaming_init/process_frame.
predict_metadata
Effects preserve shape and frame count, so the prediction is identity.
Accepts **_context so requires-aware effects (TranscriptionOverlay)
validate without subclasses needing to override just to widen the
signature. Mirrors :meth:Effect.streaming_init's **_context accept-all.
Source code in src/videopython/editing/operation.py
streaming_init
Hook for per-stream precomputation (per-frame alphas, sigma curves...).
_context carries resolved requires values for context-aware
effects (e.g. transcription=... for TranscriptionOverlay),
already re-based onto the local timeline by the runner. Effects that
declare no requires are always called without context kwargs.
Default: no-op. Override in subclasses that need it.
Source code in src/videopython/editing/operation.py
process_frame
Process one (H, W, 3) uint8 frame in streaming mode.
frame_index is 0-based within this effect's active window.
Source code in src/videopython/editing/operation.py
streams
An effect streams via per-frame Python (process_frame) or a filter.
Frame effects override :meth:process_frame; filter effects
(add_subtitles, vignette, ...) instead set
:attr:compiles_to_filter and implement :meth:to_ffmpeg_filter.
add_subtitles streams only via the filter path (it does not override
process_frame), so compiles_to_filter is consulted per-instance.
Source code in src/videopython/editing/operation.py
TimeRange
TimeRange
Bases: BaseModel
Half-open time window in seconds: [start, stop).
Either endpoint may be None, meaning "from the beginning" / "to the
end" respectively. Used by :class:Effect.window and elsewhere.
Parsing is deliberately permissive: start/stop are plain floats
with no ge=0 or ordering constraint. The plan skeleton accepts the
shape; the numeric bounds (>= 0, stop >= start, in-duration) are
owned by :meth:VideoEdit.validate / :meth:VideoEdit.check, which report
them as structured, collectable, repairable :class:PlanErrors instead of
aborting at from_dict. The window is still clamped to
min(stop, total_seconds) at run time, so a plan run without validation
degrades rather than crashes.
Source code in src/videopython/editing/operation.py
OpCategory
OpCategory
FilterCtx
FilterCtx
dataclass
Current pipeline state (post-prior-ops) when compiling to ffmpeg.
frame_count is the number of frames entering the filter at this chain
position (the plan builder folds predict_metadata through the chain),
so duration-aware compilations (a speed ramp's time-warp expression, a
freeze's frame indices) can be exact. 0 when unknown -- compilations
that need it must return None (no filter compilation) in that case.
context carries the resolved, segment-local runtime context (the same
re-based values streaming_init receives) so a context-consuming op can
compile itself into the filter chain (e.g. add_subtitles consuming the
transcription to write an .ass file). Empty when no context applies.
owned_files collects temp files a compilation creates (the .ass
file a subtitles= entry references); the plan runner deletes them once
streaming finishes or the plan is abandoned.
source_path/start_second/end_second locate the segment on
disk, and decode_filters is the decode-stage filter prefix ahead of
this op -- together they let a compilation run its own bounded decode
pass over exactly the frames the filter will see (face_crop's
detection). decode_filters is None when those frames are not
reproducible at compile time (the op sits at the encode stage, behind
per-frame Python effects); such compilations must return None.
Source code in src/videopython/editing/operation.py
audio_label
class-attribute
instance-attribute
A unique-within-the-graph prefix an audio-filter compilation can use to
name internal filter_complex labels (freeze_frame's split/concat
splice). The plan builder sets a distinct value per op so a multi-statement
audio fragment cannot collide with another op's internal labels. The
surrounding [in]<chain>[out] wrapper labels are owned by the builder;
this names only the op's internal intermediate streams.