Skip to content

Effects

Effects modify video frames without changing their count or dimensions.

Usage

from videopython.base import Video, Blur, Zoom, ColorGrading, Vignette, KenBurns, BoundingBox
from videopython.base import Fade, VolumeAdjust, TextOverlay

video = Video.from_path("input.mp4")

# Apply blur effect
blur = Blur(mode="constant", iterations=50)
video = blur.apply(video, start=0, stop=2.0)

# Apply zoom effect
zoom = Zoom(zoom_factor=1.5, mode="in")
video = zoom.apply(video)

# Color grading
grading = ColorGrading(brightness=1.1, contrast=1.2, saturation=1.1, temperature=0.1)
video = grading.apply(video)

# Vignette effect
vignette = Vignette(strength=0.5, radius=0.8)
video = vignette.apply(video)

# Ken Burns pan-and-zoom (fluent API)
start_region = BoundingBox(x=0.0, y=0.0, width=0.5, height=0.5)  # Top-left quarter
end_region = BoundingBox(x=0.5, y=0.5, width=0.5, height=0.5)    # Bottom-right quarter
video = video.ken_burns(start_region, end_region, easing="ease_in_out")

# Fade in from black over 1 second
video = Fade(mode="in", duration=1.0).apply(video)

# Fade out to black at the end
video = Fade(mode="out", duration=0.5).apply(video)

# Adjust volume (mute first 2 seconds)
video = VolumeAdjust(volume=0.0).apply(video, start=0, stop=2.0)

# Add text overlay
video = TextOverlay(text="Hello World", position=(0.5, 0.9), font_size=48).apply(video)

Effect (Base Class)

Effect

Bases: ABC

Abstract class for effect on frames of video.

The effect must not change the number of frames and the shape of the frames.

Source code in src/videopython/base/effects.py
class Effect(ABC):
    """Abstract class for effect on frames of video.

    The effect must not change the number of frames and the shape of the frames.
    """

    supports_streaming: ClassVar[bool] = False

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        """Called once before streaming begins to precompute per-frame parameters.

        Override in subclasses that need precomputation (e.g., per-frame alpha
        arrays, sigma schedules, crop regions).
        """

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        """Process a single frame in streaming mode.

        Args:
            frame: Single RGB frame (H, W, 3) uint8.
            frame_index: 0-based index within this effect's active range.

        Returns:
            Processed frame, same shape and dtype.
        """
        raise NotImplementedError(f"{type(self).__name__} does not support streaming")

    def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
        """Apply the effect to a video, optionally within a time range.

        Omit ``start`` to apply from the beginning, omit ``stop`` to apply until
        the end.  Prefer omitting over passing explicit values when the intent is
        full-range application -- this avoids floating-point mismatches with the
        actual video duration.

        Args:
            video: Input video.
            start: Start time in seconds. Omit to apply from the beginning.
                Only set when the effect should begin partway through.
            stop: Stop time in seconds. Omit to apply until the end.
                Only set when the effect should end before the video does.
        """
        original_shape = video.video_shape

        if start is None and stop is None:
            # Full-range: apply directly without slicing or np.r_ reassembly.
            video = self._apply(video)
        else:
            start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
            # Apply effect on video slice
            effect_start_frame = round(start_s * video.fps)
            effect_end_frame = round(stop_s * video.fps)
            video_with_effect = self._apply(video[effect_start_frame:effect_end_frame])
            old_audio = video.audio
            video = Video.from_frames(
                np.r_[
                    "0,2",
                    video.frames[:effect_start_frame],
                    video_with_effect.frames,
                    video.frames[effect_end_frame:],
                ],
                fps=video.fps,
            )
            video.audio = old_audio

        # Check if dimensions didn't change
        if video.video_shape != original_shape:
            raise RuntimeError("The effect must not change the number of frames and the shape of the frames!")

        return video

    @abstractmethod
    def _apply(self, video: Video) -> Video:
        pass

streaming_init

streaming_init(
    total_frames: int, fps: float, width: int, height: int
) -> None

Called once before streaming begins to precompute per-frame parameters.

Override in subclasses that need precomputation (e.g., per-frame alpha arrays, sigma schedules, crop regions).

Source code in src/videopython/base/effects.py
def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
    """Called once before streaming begins to precompute per-frame parameters.

    Override in subclasses that need precomputation (e.g., per-frame alpha
    arrays, sigma schedules, crop regions).
    """

process_frame

process_frame(
    frame: ndarray, frame_index: int
) -> np.ndarray

Process a single frame in streaming mode.

Parameters:

Name Type Description Default
frame ndarray

Single RGB frame (H, W, 3) uint8.

required
frame_index int

0-based index within this effect's active range.

required

Returns:

Type Description
ndarray

Processed frame, same shape and dtype.

Source code in src/videopython/base/effects.py
def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
    """Process a single frame in streaming mode.

    Args:
        frame: Single RGB frame (H, W, 3) uint8.
        frame_index: 0-based index within this effect's active range.

    Returns:
        Processed frame, same shape and dtype.
    """
    raise NotImplementedError(f"{type(self).__name__} does not support streaming")

apply

apply(
    video: Video,
    start: float | None = None,
    stop: float | None = None,
) -> Video

Apply the effect to a video, optionally within a time range.

Omit start to apply from the beginning, omit stop to apply until the end. Prefer omitting over passing explicit values when the intent is full-range application -- this avoids floating-point mismatches with the actual video duration.

Parameters:

Name Type Description Default
video Video

Input video.

required
start float | None

Start time in seconds. Omit to apply from the beginning. Only set when the effect should begin partway through.

None
stop float | None

Stop time in seconds. Omit to apply until the end. Only set when the effect should end before the video does.

None
Source code in src/videopython/base/effects.py
def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
    """Apply the effect to a video, optionally within a time range.

    Omit ``start`` to apply from the beginning, omit ``stop`` to apply until
    the end.  Prefer omitting over passing explicit values when the intent is
    full-range application -- this avoids floating-point mismatches with the
    actual video duration.

    Args:
        video: Input video.
        start: Start time in seconds. Omit to apply from the beginning.
            Only set when the effect should begin partway through.
        stop: Stop time in seconds. Omit to apply until the end.
            Only set when the effect should end before the video does.
    """
    original_shape = video.video_shape

    if start is None and stop is None:
        # Full-range: apply directly without slicing or np.r_ reassembly.
        video = self._apply(video)
    else:
        start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
        # Apply effect on video slice
        effect_start_frame = round(start_s * video.fps)
        effect_end_frame = round(stop_s * video.fps)
        video_with_effect = self._apply(video[effect_start_frame:effect_end_frame])
        old_audio = video.audio
        video = Video.from_frames(
            np.r_[
                "0,2",
                video.frames[:effect_start_frame],
                video_with_effect.frames,
                video.frames[effect_end_frame:],
            ],
            fps=video.fps,
        )
        video.audio = old_audio

    # Check if dimensions didn't change
    if video.video_shape != original_shape:
        raise RuntimeError("The effect must not change the number of frames and the shape of the frames!")

    return video

AudioEffect (Base Class)

Audio-only effects that inherit from Effect for execution engine compatibility but skip frame processing entirely.

AudioEffect

Bases: Effect

Abstract base class for audio-only effects.

Inherits from Effect so isinstance checks in the execution engine pass without modification. Overrides apply() to skip frame processing.

Source code in src/videopython/base/effects.py
class AudioEffect(Effect):
    """Abstract base class for audio-only effects.

    Inherits from Effect so isinstance checks in the execution engine pass
    without modification. Overrides apply() to skip frame processing.
    """

    supports_streaming: ClassVar[bool] = True

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        return frame  # Audio effects don't touch frames

    def _apply(self, video: Video) -> Video:
        raise NotImplementedError("AudioEffect does not process frames -- use _apply_audio()")

    def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
        """Apply the audio effect to a video, optionally within a time range.

        Omit ``start`` to apply from the beginning, omit ``stop`` to apply until
        the end.  Prefer omitting over passing explicit values when the intent is
        full-range application -- this avoids floating-point mismatches with the
        actual video duration.

        Args:
            video: Input video.
            start: Start time in seconds. Omit to apply from the beginning.
                Only set when the effect should begin partway through.
            stop: Stop time in seconds. Omit to apply until the end.
                Only set when the effect should end before the video does.
        """
        start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
        video.audio = self._apply_audio(video.audio, start_s, stop_s, video.fps)
        return video

    @abstractmethod
    def _apply_audio(self, audio, start: float, stop: float, fps: float):
        pass

apply

apply(
    video: Video,
    start: float | None = None,
    stop: float | None = None,
) -> Video

Apply the audio effect to a video, optionally within a time range.

Omit start to apply from the beginning, omit stop to apply until the end. Prefer omitting over passing explicit values when the intent is full-range application -- this avoids floating-point mismatches with the actual video duration.

Parameters:

Name Type Description Default
video Video

Input video.

required
start float | None

Start time in seconds. Omit to apply from the beginning. Only set when the effect should begin partway through.

None
stop float | None

Stop time in seconds. Omit to apply until the end. Only set when the effect should end before the video does.

None
Source code in src/videopython/base/effects.py
def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
    """Apply the audio effect to a video, optionally within a time range.

    Omit ``start`` to apply from the beginning, omit ``stop`` to apply until
    the end.  Prefer omitting over passing explicit values when the intent is
    full-range application -- this avoids floating-point mismatches with the
    actual video duration.

    Args:
        video: Input video.
        start: Start time in seconds. Omit to apply from the beginning.
            Only set when the effect should begin partway through.
        stop: Stop time in seconds. Omit to apply until the end.
            Only set when the effect should end before the video does.
    """
    start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)
    video.audio = self._apply_audio(video.audio, start_s, stop_s, video.fps)
    return video

Blur

Blur

Bases: Effect

Applies Gaussian blur that can stay constant or ramp up/down over the clip.

Source code in src/videopython/base/effects.py
class Blur(Effect):
    """Applies Gaussian blur that can stay constant or ramp up/down over the clip."""

    supports_streaming: ClassVar[bool] = True

    def __init__(
        self,
        mode: Literal["constant", "ascending", "descending"],
        iterations: int,
        kernel_size: tuple[int, int] = (5, 5),
    ):
        """Initialize blur effect.

        Args:
            mode: "constant" applies uniform blur, "ascending" ramps from sharp
                to blurry, "descending" ramps from blurry to sharp.
            iterations: Blur strength. Higher values produce a stronger blur
                (e.g. 5 for subtle, 50+ for heavy).
            kernel_size: Gaussian kernel [width, height] in pixels. Must be odd
                numbers. Larger kernels spread the blur wider.
        """
        if iterations < 1:
            raise ValueError("Iterations must be at least 1!")
        self.mode = mode
        self.iterations = iterations
        self.kernel_size = kernel_size

    def _blur_frame(self, frame: np.ndarray, sigma: float) -> np.ndarray:
        """Apply Gaussian blur to a single frame.

        Args:
            frame: Frame to blur.
            sigma: Gaussian sigma value.

        Returns:
            Blurred frame.
        """
        return cv2.GaussianBlur(frame, self.kernel_size, sigma)

    def _compute_sigmas(self, n_frames: int) -> np.ndarray:
        """Compute per-frame sigma values based on mode."""
        base_sigma = 0.3 * ((self.kernel_size[0] - 1) * 0.5 - 1) + 0.8
        max_sigma = base_sigma * np.sqrt(self.iterations)

        if self.mode == "constant":
            return np.full(n_frames, max_sigma)
        elif self.mode == "ascending":
            ratios = np.linspace(1 / n_frames, 1.0, n_frames)
        elif self.mode == "descending":
            ratios = np.linspace(1.0, 1 / n_frames, n_frames)
        else:
            raise ValueError(f"Unknown mode: `{self.mode}`.")
        return base_sigma * np.sqrt(np.maximum(1, np.round(ratios * self.iterations)))

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        self._stream_sigmas = self._compute_sigmas(total_frames)

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        idx = min(frame_index, len(self._stream_sigmas) - 1)
        return self._blur_frame(frame, self._stream_sigmas[idx])

    def _apply(self, video: Video) -> Video:
        n_frames = len(video.frames)
        sigmas = self._compute_sigmas(n_frames)

        log(f"Applying {self.mode} blur...")
        for i in progress_iter(range(n_frames), desc="Blurring"):
            video.frames[i] = self._blur_frame(video.frames[i], sigmas[i])
        return video

__init__

__init__(
    mode: Literal["constant", "ascending", "descending"],
    iterations: int,
    kernel_size: tuple[int, int] = (5, 5),
)

Initialize blur effect.

Parameters:

Name Type Description Default
mode Literal['constant', 'ascending', 'descending']

"constant" applies uniform blur, "ascending" ramps from sharp to blurry, "descending" ramps from blurry to sharp.

required
iterations int

Blur strength. Higher values produce a stronger blur (e.g. 5 for subtle, 50+ for heavy).

required
kernel_size tuple[int, int]

Gaussian kernel [width, height] in pixels. Must be odd numbers. Larger kernels spread the blur wider.

(5, 5)
Source code in src/videopython/base/effects.py
def __init__(
    self,
    mode: Literal["constant", "ascending", "descending"],
    iterations: int,
    kernel_size: tuple[int, int] = (5, 5),
):
    """Initialize blur effect.

    Args:
        mode: "constant" applies uniform blur, "ascending" ramps from sharp
            to blurry, "descending" ramps from blurry to sharp.
        iterations: Blur strength. Higher values produce a stronger blur
            (e.g. 5 for subtle, 50+ for heavy).
        kernel_size: Gaussian kernel [width, height] in pixels. Must be odd
            numbers. Larger kernels spread the blur wider.
    """
    if iterations < 1:
        raise ValueError("Iterations must be at least 1!")
    self.mode = mode
    self.iterations = iterations
    self.kernel_size = kernel_size

Zoom

Zoom

Bases: Effect

Progressively zooms into or out of the frame center over the clip duration.

Source code in src/videopython/base/effects.py
class Zoom(Effect):
    """Progressively zooms into or out of the frame center over the clip duration."""

    supports_streaming: ClassVar[bool] = True

    def __init__(self, zoom_factor: float, mode: Literal["in", "out"]):
        """Initialize zoom effect.

        Args:
            zoom_factor: How far to zoom. 1.5 is a subtle push, 2.0 is
                moderate, 3.0+ is dramatic. Must be greater than 1.
            mode: "in" starts wide and pushes into the center,
                "out" starts tight and pulls back.
        """
        if zoom_factor <= 1:
            raise ValueError("Zoom factor must be greater than 1!")
        self.zoom_factor = zoom_factor
        self.mode = mode

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        crop_w = np.linspace(width // self.zoom_factor, width, total_frames)
        crop_h = np.linspace(height // self.zoom_factor, height, total_frames)
        if self.mode == "in":
            crop_w, crop_h = crop_w[::-1], crop_h[::-1]
        self._stream_crops = np.stack([crop_w, crop_h], axis=1)
        self._stream_width = width
        self._stream_height = height

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        idx = min(frame_index, len(self._stream_crops) - 1)
        w, h = self._stream_crops[idx]
        width, height = self._stream_width, self._stream_height
        x = width / 2 - w / 2
        y = height / 2 - h / 2
        cropped = frame[round(y) : round(y + h), round(x) : round(x + w)]
        return cv2.resize(cropped, (width, height))

    def _apply(self, video: Video) -> Video:
        n_frames = len(video.frames)
        width = video.metadata.width
        height = video.metadata.height
        crop_sizes_w = np.linspace(width // self.zoom_factor, width, n_frames)
        crop_sizes_h = np.linspace(height // self.zoom_factor, height, n_frames)

        if self.mode == "in":
            crop_sizes_w = crop_sizes_w[::-1]
            crop_sizes_h = crop_sizes_h[::-1]
        elif self.mode != "out":
            raise ValueError(f"Unknown mode: `{self.mode}`.")

        for i in progress_iter(range(n_frames), desc="Zooming", total=n_frames):
            w, h = crop_sizes_w[i], crop_sizes_h[i]
            x = width / 2 - w / 2
            y = height / 2 - h / 2
            cropped_frame = video.frames[i][round(y) : round(y + h), round(x) : round(x + w)]
            video.frames[i] = cv2.resize(cropped_frame, (width, height))
        return video

__init__

__init__(zoom_factor: float, mode: Literal['in', 'out'])

Initialize zoom effect.

Parameters:

Name Type Description Default
zoom_factor float

How far to zoom. 1.5 is a subtle push, 2.0 is moderate, 3.0+ is dramatic. Must be greater than 1.

required
mode Literal['in', 'out']

"in" starts wide and pushes into the center, "out" starts tight and pulls back.

required
Source code in src/videopython/base/effects.py
def __init__(self, zoom_factor: float, mode: Literal["in", "out"]):
    """Initialize zoom effect.

    Args:
        zoom_factor: How far to zoom. 1.5 is a subtle push, 2.0 is
            moderate, 3.0+ is dramatic. Must be greater than 1.
        mode: "in" starts wide and pushes into the center,
            "out" starts tight and pulls back.
    """
    if zoom_factor <= 1:
        raise ValueError("Zoom factor must be greater than 1!")
    self.zoom_factor = zoom_factor
    self.mode = mode

FullImageOverlay

FullImageOverlay

Bases: Effect

Composites a full-frame image on top of every video frame.

Useful for watermarks, logos, or static graphic overlays. Supports transparency via RGBA images and an overall opacity control.

Source code in src/videopython/base/effects.py
class FullImageOverlay(Effect):
    """Composites a full-frame image on top of every video frame.

    Useful for watermarks, logos, or static graphic overlays. Supports
    transparency via RGBA images and an overall opacity control.
    """

    supports_streaming: ClassVar[bool] = True

    def __init__(self, overlay_image: np.ndarray, alpha: float | None = None, fade_time: float = 0.0):
        """Initialize image overlay effect.

        Args:
            overlay_image: RGB or RGBA image array. Must match the video's
                width and height.
            alpha: Overall opacity. 0 = fully transparent, 1 = fully opaque.
                Defaults to 1.0.
            fade_time: Seconds to fade the overlay in at the start and out
                at the end of its time range.
        """
        if alpha is not None and not 0 <= alpha <= 1:
            raise ValueError("Alpha must be in range [0, 1]!")
        elif not (overlay_image.ndim == 3 and overlay_image.shape[-1] in [3, 4]):
            raise ValueError("Only RGB and RGBA images are supported as an overlay!")
        elif alpha is None:
            alpha = 1.0

        if overlay_image.shape[-1] == 3:
            overlay_image = np.dstack([overlay_image, np.full(overlay_image.shape[:2], 255, dtype=np.uint8)])

        self.alpha = alpha
        self.overlay = overlay_image.astype(np.uint8)
        self.fade_time = fade_time

    def _overlay(self, img: np.ndarray, alpha: float = 1.0) -> np.ndarray:
        img_pil = Image.fromarray(img)
        overlay = self.overlay.copy()
        overlay[:, :, 3] = overlay[:, :, 3] * (self.alpha * alpha)
        overlay_pil = Image.fromarray(overlay)
        img_pil.paste(overlay_pil, (0, 0), overlay_pil)
        return np.array(img_pil)

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        self._stream_total = total_frames
        self._stream_fade_frames = round(self.fade_time * fps) if self.fade_time > 0 else 0

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        if self._stream_fade_frames == 0:
            return self._overlay(frame)
        dist_from_end = min(frame_index, self._stream_total - 1 - frame_index)
        fade_alpha = 1.0 if dist_from_end >= self._stream_fade_frames else dist_from_end / self._stream_fade_frames
        return self._overlay(frame, fade_alpha)

    def _apply(self, video: Video) -> Video:
        if not video.frame_shape == self.overlay[:, :, :3].shape:
            raise ValueError(
                f"Mismatch of overlay shape `{self.overlay.shape}` with video shape: `{video.frame_shape}`!"
            )
        elif not (0 <= 2 * self.fade_time <= video.total_seconds):
            raise ValueError(f"Video is only {video.total_seconds}s long, but fade time is {self.fade_time}s!")

        log("Overlaying video...")
        if self.fade_time == 0:
            for i in progress_iter(range(len(video.frames)), desc="Overlaying frames"):
                video.frames[i] = self._overlay(video.frames[i])
        else:
            num_video_frames = len(video.frames)
            num_fade_frames = round(self.fade_time * video.fps)
            for i in progress_iter(range(num_video_frames), desc="Overlaying frames"):
                frames_dist_from_end = min(i, num_video_frames - i)
                fade_alpha = 1.0 if frames_dist_from_end >= num_fade_frames else frames_dist_from_end / num_fade_frames
                video.frames[i] = self._overlay(video.frames[i], fade_alpha)
        return video

__init__

__init__(
    overlay_image: ndarray,
    alpha: float | None = None,
    fade_time: float = 0.0,
)

Initialize image overlay effect.

Parameters:

Name Type Description Default
overlay_image ndarray

RGB or RGBA image array. Must match the video's width and height.

required
alpha float | None

Overall opacity. 0 = fully transparent, 1 = fully opaque. Defaults to 1.0.

None
fade_time float

Seconds to fade the overlay in at the start and out at the end of its time range.

0.0
Source code in src/videopython/base/effects.py
def __init__(self, overlay_image: np.ndarray, alpha: float | None = None, fade_time: float = 0.0):
    """Initialize image overlay effect.

    Args:
        overlay_image: RGB or RGBA image array. Must match the video's
            width and height.
        alpha: Overall opacity. 0 = fully transparent, 1 = fully opaque.
            Defaults to 1.0.
        fade_time: Seconds to fade the overlay in at the start and out
            at the end of its time range.
    """
    if alpha is not None and not 0 <= alpha <= 1:
        raise ValueError("Alpha must be in range [0, 1]!")
    elif not (overlay_image.ndim == 3 and overlay_image.shape[-1] in [3, 4]):
        raise ValueError("Only RGB and RGBA images are supported as an overlay!")
    elif alpha is None:
        alpha = 1.0

    if overlay_image.shape[-1] == 3:
        overlay_image = np.dstack([overlay_image, np.full(overlay_image.shape[:2], 255, dtype=np.uint8)])

    self.alpha = alpha
    self.overlay = overlay_image.astype(np.uint8)
    self.fade_time = fade_time

ColorGrading

ColorGrading

Bases: Effect

Adjusts color properties: brightness, contrast, saturation, and temperature.

Source code in src/videopython/base/effects.py
class ColorGrading(Effect):
    """Adjusts color properties: brightness, contrast, saturation, and temperature."""

    supports_streaming: ClassVar[bool] = True

    def __init__(
        self,
        brightness: float = 0.0,
        contrast: float = 1.0,
        saturation: float = 1.0,
        temperature: float = 0.0,
    ):
        """Initialize color grading effect.

        Args:
            brightness: Shift brightness. -1.0 = much darker, 0 = unchanged,
                1.0 = much brighter.
            contrast: Scale contrast. 0.5 = flat/washed out, 1.0 = unchanged,
                2.0 = high contrast.
            saturation: Scale color intensity. 0.0 = grayscale, 1.0 = unchanged,
                2.0 = vivid/oversaturated.
            temperature: Shift color temperature. -1.0 = cool/blue tint,
                0 = neutral, 1.0 = warm/orange tint.
        """
        if not -1.0 <= brightness <= 1.0:
            raise ValueError("Brightness must be between -1.0 and 1.0!")
        if not 0.5 <= contrast <= 2.0:
            raise ValueError("Contrast must be between 0.5 and 2.0!")
        if not 0.0 <= saturation <= 2.0:
            raise ValueError("Saturation must be between 0.0 and 2.0!")
        if not -1.0 <= temperature <= 1.0:
            raise ValueError("Temperature must be between -1.0 and 1.0!")

        self.brightness = brightness
        self.contrast = contrast
        self.saturation = saturation
        self.temperature = temperature

    def _grade_frame(self, frame: np.ndarray) -> np.ndarray:
        """Apply color grading to a single frame."""
        # Convert to float for processing
        img = frame.astype(np.float32) / 255.0

        # Apply brightness
        if self.brightness != 0:
            img = img + self.brightness

        # Apply contrast (around midpoint 0.5)
        if self.contrast != 1.0:
            img = (img - 0.5) * self.contrast + 0.5

        # Apply saturation in HSV space
        if self.saturation != 1.0:
            hsv = cv2.cvtColor(np.clip(img, 0, 1).astype(np.float32), cv2.COLOR_RGB2HSV)
            hsv[:, :, 1] = hsv[:, :, 1] * self.saturation
            hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 1)
            img = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB).astype(np.float32)

        # Apply temperature (shift red/blue channels)
        if self.temperature != 0:
            # Warm = more red/yellow, less blue
            # Cool = more blue, less red/yellow
            temp_shift = self.temperature * 0.1
            img[:, :, 0] = img[:, :, 0] + temp_shift  # Red
            img[:, :, 2] = img[:, :, 2] - temp_shift  # Blue

        # Clip and convert back to uint8
        img = np.clip(img * 255, 0, 255).astype(np.uint8)
        return img

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        return self._grade_frame(frame)

    def _apply(self, video: Video) -> Video:
        log("Applying color grading...")
        for i in progress_iter(range(len(video.frames)), desc="Color grading"):
            video.frames[i] = self._grade_frame(video.frames[i])
        return video

__init__

__init__(
    brightness: float = 0.0,
    contrast: float = 1.0,
    saturation: float = 1.0,
    temperature: float = 0.0,
)

Initialize color grading effect.

Parameters:

Name Type Description Default
brightness float

Shift brightness. -1.0 = much darker, 0 = unchanged, 1.0 = much brighter.

0.0
contrast float

Scale contrast. 0.5 = flat/washed out, 1.0 = unchanged, 2.0 = high contrast.

1.0
saturation float

Scale color intensity. 0.0 = grayscale, 1.0 = unchanged, 2.0 = vivid/oversaturated.

1.0
temperature float

Shift color temperature. -1.0 = cool/blue tint, 0 = neutral, 1.0 = warm/orange tint.

0.0
Source code in src/videopython/base/effects.py
def __init__(
    self,
    brightness: float = 0.0,
    contrast: float = 1.0,
    saturation: float = 1.0,
    temperature: float = 0.0,
):
    """Initialize color grading effect.

    Args:
        brightness: Shift brightness. -1.0 = much darker, 0 = unchanged,
            1.0 = much brighter.
        contrast: Scale contrast. 0.5 = flat/washed out, 1.0 = unchanged,
            2.0 = high contrast.
        saturation: Scale color intensity. 0.0 = grayscale, 1.0 = unchanged,
            2.0 = vivid/oversaturated.
        temperature: Shift color temperature. -1.0 = cool/blue tint,
            0 = neutral, 1.0 = warm/orange tint.
    """
    if not -1.0 <= brightness <= 1.0:
        raise ValueError("Brightness must be between -1.0 and 1.0!")
    if not 0.5 <= contrast <= 2.0:
        raise ValueError("Contrast must be between 0.5 and 2.0!")
    if not 0.0 <= saturation <= 2.0:
        raise ValueError("Saturation must be between 0.0 and 2.0!")
    if not -1.0 <= temperature <= 1.0:
        raise ValueError("Temperature must be between -1.0 and 1.0!")

    self.brightness = brightness
    self.contrast = contrast
    self.saturation = saturation
    self.temperature = temperature

Vignette

Vignette

Bases: Effect

Darkens the edges of the frame, drawing attention to the center.

Source code in src/videopython/base/effects.py
class Vignette(Effect):
    """Darkens the edges of the frame, drawing attention to the center."""

    supports_streaming: ClassVar[bool] = True

    def __init__(self, strength: float = 0.5, radius: float = 1.0):
        """Initialize vignette effect.

        Args:
            strength: Edge darkness amount. 0.0 = no darkening, 0.5 = moderate,
                1.0 = fully black edges.
            radius: Size of the bright center area. Smaller values (0.5) create
                a tight spotlight, larger values (2.0) keep more of the frame lit.
        """
        if not 0.0 <= strength <= 1.0:
            raise ValueError("Strength must be between 0.0 and 1.0!")
        if not 0.5 <= radius <= 2.0:
            raise ValueError("Radius must be between 0.5 and 2.0!")

        self.strength = strength
        self.radius = radius
        self._mask: np.ndarray | None = None

    def _create_mask(self, height: int, width: int) -> np.ndarray:
        """Create vignette mask for given dimensions."""
        # Create coordinate grids
        y = np.linspace(-1, 1, height)
        x = np.linspace(-1, 1, width)
        X, Y = np.meshgrid(x, y)

        # Calculate distance from center
        distance = np.sqrt(X**2 + Y**2) / self.radius

        # Create smooth falloff
        mask = 1.0 - np.clip(distance - 0.5, 0, 1) * 2 * self.strength

        return mask.astype(np.float32)

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        if self._mask is None or self._mask.shape != (height, width):
            self._mask = self._create_mask(height, width)
        self._stream_mask_3d = self._mask[:, :, np.newaxis]

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        return (frame.astype(np.float32) * self._stream_mask_3d).astype(np.uint8)

    def _apply(self, video: Video) -> Video:
        log("Applying vignette effect...")
        height, width = video.frame_shape[:2]

        # Create mask once for the video dimensions
        if self._mask is None or self._mask.shape != (height, width):
            self._mask = self._create_mask(height, width)

        # Apply mask in batches to avoid allocating a full float32 copy of all frames
        mask_3d = self._mask[:, :, np.newaxis]
        batch_size = 64
        for start in range(0, len(video.frames), batch_size):
            end = min(start + batch_size, len(video.frames))
            video.frames[start:end] = (video.frames[start:end].astype(np.float32) * mask_3d).astype(np.uint8)
        return video

__init__

__init__(strength: float = 0.5, radius: float = 1.0)

Initialize vignette effect.

Parameters:

Name Type Description Default
strength float

Edge darkness amount. 0.0 = no darkening, 0.5 = moderate, 1.0 = fully black edges.

0.5
radius float

Size of the bright center area. Smaller values (0.5) create a tight spotlight, larger values (2.0) keep more of the frame lit.

1.0
Source code in src/videopython/base/effects.py
def __init__(self, strength: float = 0.5, radius: float = 1.0):
    """Initialize vignette effect.

    Args:
        strength: Edge darkness amount. 0.0 = no darkening, 0.5 = moderate,
            1.0 = fully black edges.
        radius: Size of the bright center area. Smaller values (0.5) create
            a tight spotlight, larger values (2.0) keep more of the frame lit.
    """
    if not 0.0 <= strength <= 1.0:
        raise ValueError("Strength must be between 0.0 and 1.0!")
    if not 0.5 <= radius <= 2.0:
        raise ValueError("Radius must be between 0.5 and 2.0!")

    self.strength = strength
    self.radius = radius
    self._mask: np.ndarray | None = None

KenBurns

KenBurns

Bases: Effect

Cinematic pan-and-zoom that smoothly animates between two crop regions.

Creates movement by transitioning from a start region to an end region over the clip. Use it to add motion to still images or to guide the viewer's eye across a scene.

Source code in src/videopython/base/effects.py
class KenBurns(Effect):
    """Cinematic pan-and-zoom that smoothly animates between two crop regions.

    Creates movement by transitioning from a start region to an end region over
    the clip. Use it to add motion to still images or to guide the viewer's eye
    across a scene.
    """

    supports_streaming: ClassVar[bool] = True

    def __init__(
        self,
        start_region: "BoundingBox",
        end_region: "BoundingBox",
        easing: Literal["linear", "ease_in", "ease_out", "ease_in_out"] = "linear",
    ):
        """Initialize Ken Burns effect.

        Args:
            start_region: Starting crop region as a BoundingBox with normalized
                0-1 coordinates.
            end_region: Ending crop region as a BoundingBox with normalized
                0-1 coordinates.
            easing: Animation curve. "linear" moves at constant speed,
                "ease_in" starts slow, "ease_out" ends slow,
                "ease_in_out" starts and ends slow.
        """
        from videopython.base.description import BoundingBox

        if not isinstance(start_region, BoundingBox) or not isinstance(end_region, BoundingBox):
            raise TypeError("start_region and end_region must be BoundingBox instances!")

        # Validate regions are within bounds
        for name, region in [("start_region", start_region), ("end_region", end_region)]:
            if not (0 <= region.x <= 1 and 0 <= region.y <= 1):
                raise ValueError(f"{name} position must be in range [0, 1]!")
            if not (0 < region.width <= 1 and 0 < region.height <= 1):
                raise ValueError(f"{name} dimensions must be in range (0, 1]!")
            if region.x + region.width > 1 or region.y + region.height > 1:
                raise ValueError(f"{name} extends beyond image bounds!")

        if easing not in ("linear", "ease_in", "ease_out", "ease_in_out"):
            raise ValueError(f"Unknown easing function: {easing}!")

        self.start_region = start_region
        self.end_region = end_region
        self.easing = easing

    def _ease(self, t: float) -> float:
        """Apply easing function to normalized time value.

        Args:
            t: Normalized time value in range [0, 1].

        Returns:
            Eased value in range [0, 1].
        """
        if self.easing == "linear":
            return t
        elif self.easing == "ease_in":
            return t * t
        elif self.easing == "ease_out":
            return 1 - (1 - t) * (1 - t)
        elif self.easing == "ease_in_out":
            if t < 0.5:
                return 2 * t * t
            else:
                return 1 - 2 * (1 - t) * (1 - t)
        return t

    def _crop_and_scale_frame(
        self,
        frame: np.ndarray,
        x: int,
        y: int,
        crop_w: int,
        crop_h: int,
        target_w: int,
        target_h: int,
    ) -> np.ndarray:
        """Crop region from frame and scale to target size."""
        cropped = frame[y : y + crop_h, x : x + crop_w]
        return cv2.resize(cropped, (target_w, target_h), interpolation=cv2.INTER_LINEAR)

    def _precompute_regions(self, n_frames: int, width: int, height: int) -> np.ndarray:
        """Precompute (x, y, crop_w, crop_h) for each frame."""
        sx = int(self.start_region.x * width)
        sy = int(self.start_region.y * height)
        sw = int(self.start_region.width * width)
        sh = int(self.start_region.height * height)
        ex = int(self.end_region.x * width)
        ey = int(self.end_region.y * height)
        ew = int(self.end_region.width * width)
        eh = int(self.end_region.height * height)

        regions = np.empty((n_frames, 4), dtype=np.int32)
        for i in range(n_frames):
            t = i / max(1, n_frames - 1)
            et = self._ease(t)
            crop_w = int(sw + (ew - sw) * et)
            crop_h = int(sh + (eh - sh) * et)
            x = max(0, min(int(sx + (ex - sx) * et), width - crop_w))
            y = max(0, min(int(sy + (ey - sy) * et), height - crop_h))
            regions[i] = (x, y, crop_w, crop_h)
        return regions

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        self._stream_regions = self._precompute_regions(total_frames, width, height)
        self._stream_target_w = width
        self._stream_target_h = height

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        idx = min(frame_index, len(self._stream_regions) - 1)
        x, y, cw, ch = self._stream_regions[idx]
        return self._crop_and_scale_frame(frame, x, y, cw, ch, self._stream_target_w, self._stream_target_h)

    def _apply(self, video: Video) -> Video:
        n_frames = len(video.frames)
        height, width = video.frame_shape[:2]
        target_h, target_w = height, width

        # Convert normalized coordinates to pixel values
        start_x = int(self.start_region.x * width)
        start_y = int(self.start_region.y * height)
        start_w = int(self.start_region.width * width)
        start_h = int(self.start_region.height * height)

        end_x = int(self.end_region.x * width)
        end_y = int(self.end_region.y * height)
        end_w = int(self.end_region.width * width)
        end_h = int(self.end_region.height * height)

        log("Applying Ken Burns effect...")
        for i in progress_iter(range(n_frames), desc="Ken Burns"):
            t = i / max(1, n_frames - 1)  # Normalized time [0, 1]
            eased_t = self._ease(t)

            # Interpolate region parameters
            x = int(start_x + (end_x - start_x) * eased_t)
            y = int(start_y + (end_y - start_y) * eased_t)
            crop_w = int(start_w + (end_w - start_w) * eased_t)
            crop_h = int(start_h + (end_h - start_h) * eased_t)

            # Ensure crop region stays within bounds
            x = max(0, min(x, width - crop_w))
            y = max(0, min(y, height - crop_h))

            video.frames[i] = self._crop_and_scale_frame(video.frames[i], x, y, crop_w, crop_h, target_w, target_h)
        return video

__init__

__init__(
    start_region: "BoundingBox",
    end_region: "BoundingBox",
    easing: Literal[
        "linear", "ease_in", "ease_out", "ease_in_out"
    ] = "linear",
)

Initialize Ken Burns effect.

Parameters:

Name Type Description Default
start_region 'BoundingBox'

Starting crop region as a BoundingBox with normalized 0-1 coordinates.

required
end_region 'BoundingBox'

Ending crop region as a BoundingBox with normalized 0-1 coordinates.

required
easing Literal['linear', 'ease_in', 'ease_out', 'ease_in_out']

Animation curve. "linear" moves at constant speed, "ease_in" starts slow, "ease_out" ends slow, "ease_in_out" starts and ends slow.

'linear'
Source code in src/videopython/base/effects.py
def __init__(
    self,
    start_region: "BoundingBox",
    end_region: "BoundingBox",
    easing: Literal["linear", "ease_in", "ease_out", "ease_in_out"] = "linear",
):
    """Initialize Ken Burns effect.

    Args:
        start_region: Starting crop region as a BoundingBox with normalized
            0-1 coordinates.
        end_region: Ending crop region as a BoundingBox with normalized
            0-1 coordinates.
        easing: Animation curve. "linear" moves at constant speed,
            "ease_in" starts slow, "ease_out" ends slow,
            "ease_in_out" starts and ends slow.
    """
    from videopython.base.description import BoundingBox

    if not isinstance(start_region, BoundingBox) or not isinstance(end_region, BoundingBox):
        raise TypeError("start_region and end_region must be BoundingBox instances!")

    # Validate regions are within bounds
    for name, region in [("start_region", start_region), ("end_region", end_region)]:
        if not (0 <= region.x <= 1 and 0 <= region.y <= 1):
            raise ValueError(f"{name} position must be in range [0, 1]!")
        if not (0 < region.width <= 1 and 0 < region.height <= 1):
            raise ValueError(f"{name} dimensions must be in range (0, 1]!")
        if region.x + region.width > 1 or region.y + region.height > 1:
            raise ValueError(f"{name} extends beyond image bounds!")

    if easing not in ("linear", "ease_in", "ease_out", "ease_in_out"):
        raise ValueError(f"Unknown easing function: {easing}!")

    self.start_region = start_region
    self.end_region = end_region
    self.easing = easing

Fade

Fade

Bases: Effect

Fades video and audio to or from black.

Source code in src/videopython/base/effects.py
class Fade(Effect):
    """Fades video and audio to or from black."""

    supports_streaming: ClassVar[bool] = True

    def __init__(
        self,
        mode: Literal["in", "out", "in_out"],
        duration: float = 1.0,
        curve: Literal["sqrt", "linear", "exponential"] = "sqrt",
    ):
        """Initialize fade effect.

        Args:
            mode: "in" fades from black at the start, "out" fades to black
                at the end, "in_out" does both.
            duration: Length of each fade in seconds.
            curve: Brightness ramp shape. "sqrt" feels perceptually even
                (recommended), "linear" is mathematically even, "exponential"
                starts slow and finishes fast.
        """
        if mode not in ("in", "out", "in_out"):
            raise ValueError(f"mode must be 'in', 'out', or 'in_out', got '{mode}'")
        if duration <= 0:
            raise ValueError(f"duration must be > 0, got {duration}")
        self.mode = mode
        self.duration = duration
        self.curve = curve

    def _compute_alpha(self, n_frames: int, fps: float) -> np.ndarray:
        """Compute per-frame alpha values for the video fade."""
        fade_frames = min(round(self.duration * fps), n_frames)
        alpha = np.ones(n_frames, dtype=np.float32)
        if self.mode in ("in", "in_out"):
            t = np.linspace(0, 1, fade_frames, dtype=np.float32)
            alpha[:fade_frames] = _compute_curve(t, self.curve)
        if self.mode in ("out", "in_out"):
            t = np.linspace(1, 0, fade_frames, dtype=np.float32)
            alpha[-fade_frames:] = np.minimum(alpha[-fade_frames:], _compute_curve(t, self.curve))
        return alpha

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        self._stream_alpha = self._compute_alpha(total_frames, fps)

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        idx = min(frame_index, len(self._stream_alpha) - 1)
        a = self._stream_alpha[idx]
        if a == 1.0:
            return frame
        return (frame.astype(np.float32) * a).astype(np.uint8)

    def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
        """Apply fade effect to video and audio.

        Omit ``start`` to apply from the beginning, omit ``stop`` to apply
        until the end. Prefer omitting over passing explicit values when
        the intent is full-range application.

        Args:
            video: Input video.
            start: Start time in seconds. Omit to apply from the beginning.
                Only set when the effect should begin partway through.
            stop: Stop time in seconds. Omit to apply until the end.
                Only set when the effect should end before the video does.
        """
        original_shape = video.video_shape
        start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)

        effect_start_frame = round(start_s * video.fps)
        effect_end_frame = round(stop_s * video.fps)
        n_effect_frames = effect_end_frame - effect_start_frame

        alpha = self._compute_alpha(n_effect_frames, video.fps)

        # Apply to video frames in batches to avoid a full float32 copy
        batch_size = 64
        for batch_start in range(0, n_effect_frames, batch_size):
            batch_end = min(batch_start + batch_size, n_effect_frames)
            batch_alpha = alpha[batch_start:batch_end, np.newaxis, np.newaxis, np.newaxis]
            # Skip batch if all alphas are 1.0 (no change needed)
            if np.all(batch_alpha == 1.0):
                continue
            abs_start = effect_start_frame + batch_start
            abs_end = effect_start_frame + batch_end
            video.frames[abs_start:abs_end] = (video.frames[abs_start:abs_end].astype(np.float32) * batch_alpha).astype(
                np.uint8
            )

        # Verify shape invariant
        if video.video_shape != original_shape:
            raise RuntimeError("The effect must not change the number of frames and the shape of the frames!")

        # Apply to audio
        if video.audio is not None and not video.audio.is_silent:
            self.apply_audio(video.audio, start_s, stop_s)

        return video

    def apply_audio(self, audio: Audio, start_s: float, stop_s: float) -> None:
        """Apply fade to audio data in-place.

        Args:
            audio: Audio object to modify.
            start_s: Start time in seconds.
            stop_s: Stop time in seconds.
        """
        sample_rate = audio.metadata.sample_rate
        audio_start = round(start_s * sample_rate)
        audio_end = min(round(stop_s * sample_rate), len(audio.data))
        n_samples = audio_end - audio_start
        fade_samples = min(round(self.duration * sample_rate), n_samples)

        alpha = np.ones(n_samples, dtype=np.float32)
        if self.mode in ("in", "in_out"):
            t = np.linspace(0, 1, fade_samples, dtype=np.float32)
            alpha[:fade_samples] = _compute_curve(t, self.curve)
        if self.mode in ("out", "in_out"):
            t = np.linspace(1, 0, fade_samples, dtype=np.float32)
            alpha[-fade_samples:] = np.minimum(alpha[-fade_samples:], _compute_curve(t, self.curve))

        if audio.data.ndim == 1:
            audio.data[audio_start:audio_end] *= alpha
        else:
            audio.data[audio_start:audio_end] *= alpha[:, np.newaxis]
        np.clip(audio.data, -1.0, 1.0, out=audio.data)

    def _apply(self, video: Video) -> Video:
        raise NotImplementedError("Fade overrides apply() directly")

__init__

__init__(
    mode: Literal["in", "out", "in_out"],
    duration: float = 1.0,
    curve: Literal[
        "sqrt", "linear", "exponential"
    ] = "sqrt",
)

Initialize fade effect.

Parameters:

Name Type Description Default
mode Literal['in', 'out', 'in_out']

"in" fades from black at the start, "out" fades to black at the end, "in_out" does both.

required
duration float

Length of each fade in seconds.

1.0
curve Literal['sqrt', 'linear', 'exponential']

Brightness ramp shape. "sqrt" feels perceptually even (recommended), "linear" is mathematically even, "exponential" starts slow and finishes fast.

'sqrt'
Source code in src/videopython/base/effects.py
def __init__(
    self,
    mode: Literal["in", "out", "in_out"],
    duration: float = 1.0,
    curve: Literal["sqrt", "linear", "exponential"] = "sqrt",
):
    """Initialize fade effect.

    Args:
        mode: "in" fades from black at the start, "out" fades to black
            at the end, "in_out" does both.
        duration: Length of each fade in seconds.
        curve: Brightness ramp shape. "sqrt" feels perceptually even
            (recommended), "linear" is mathematically even, "exponential"
            starts slow and finishes fast.
    """
    if mode not in ("in", "out", "in_out"):
        raise ValueError(f"mode must be 'in', 'out', or 'in_out', got '{mode}'")
    if duration <= 0:
        raise ValueError(f"duration must be > 0, got {duration}")
    self.mode = mode
    self.duration = duration
    self.curve = curve

apply

apply(
    video: Video,
    start: float | None = None,
    stop: float | None = None,
) -> Video

Apply fade effect to video and audio.

Omit start to apply from the beginning, omit stop to apply until the end. Prefer omitting over passing explicit values when the intent is full-range application.

Parameters:

Name Type Description Default
video Video

Input video.

required
start float | None

Start time in seconds. Omit to apply from the beginning. Only set when the effect should begin partway through.

None
stop float | None

Stop time in seconds. Omit to apply until the end. Only set when the effect should end before the video does.

None
Source code in src/videopython/base/effects.py
def apply(self, video: Video, start: float | None = None, stop: float | None = None) -> Video:
    """Apply fade effect to video and audio.

    Omit ``start`` to apply from the beginning, omit ``stop`` to apply
    until the end. Prefer omitting over passing explicit values when
    the intent is full-range application.

    Args:
        video: Input video.
        start: Start time in seconds. Omit to apply from the beginning.
            Only set when the effect should begin partway through.
        stop: Stop time in seconds. Omit to apply until the end.
            Only set when the effect should end before the video does.
    """
    original_shape = video.video_shape
    start_s, stop_s = _resolve_time_range(start, stop, video.total_seconds)

    effect_start_frame = round(start_s * video.fps)
    effect_end_frame = round(stop_s * video.fps)
    n_effect_frames = effect_end_frame - effect_start_frame

    alpha = self._compute_alpha(n_effect_frames, video.fps)

    # Apply to video frames in batches to avoid a full float32 copy
    batch_size = 64
    for batch_start in range(0, n_effect_frames, batch_size):
        batch_end = min(batch_start + batch_size, n_effect_frames)
        batch_alpha = alpha[batch_start:batch_end, np.newaxis, np.newaxis, np.newaxis]
        # Skip batch if all alphas are 1.0 (no change needed)
        if np.all(batch_alpha == 1.0):
            continue
        abs_start = effect_start_frame + batch_start
        abs_end = effect_start_frame + batch_end
        video.frames[abs_start:abs_end] = (video.frames[abs_start:abs_end].astype(np.float32) * batch_alpha).astype(
            np.uint8
        )

    # Verify shape invariant
    if video.video_shape != original_shape:
        raise RuntimeError("The effect must not change the number of frames and the shape of the frames!")

    # Apply to audio
    if video.audio is not None and not video.audio.is_silent:
        self.apply_audio(video.audio, start_s, stop_s)

    return video

apply_audio

apply_audio(
    audio: Audio, start_s: float, stop_s: float
) -> None

Apply fade to audio data in-place.

Parameters:

Name Type Description Default
audio Audio

Audio object to modify.

required
start_s float

Start time in seconds.

required
stop_s float

Stop time in seconds.

required
Source code in src/videopython/base/effects.py
def apply_audio(self, audio: Audio, start_s: float, stop_s: float) -> None:
    """Apply fade to audio data in-place.

    Args:
        audio: Audio object to modify.
        start_s: Start time in seconds.
        stop_s: Stop time in seconds.
    """
    sample_rate = audio.metadata.sample_rate
    audio_start = round(start_s * sample_rate)
    audio_end = min(round(stop_s * sample_rate), len(audio.data))
    n_samples = audio_end - audio_start
    fade_samples = min(round(self.duration * sample_rate), n_samples)

    alpha = np.ones(n_samples, dtype=np.float32)
    if self.mode in ("in", "in_out"):
        t = np.linspace(0, 1, fade_samples, dtype=np.float32)
        alpha[:fade_samples] = _compute_curve(t, self.curve)
    if self.mode in ("out", "in_out"):
        t = np.linspace(1, 0, fade_samples, dtype=np.float32)
        alpha[-fade_samples:] = np.minimum(alpha[-fade_samples:], _compute_curve(t, self.curve))

    if audio.data.ndim == 1:
        audio.data[audio_start:audio_end] *= alpha
    else:
        audio.data[audio_start:audio_end] *= alpha[:, np.newaxis]
    np.clip(audio.data, -1.0, 1.0, out=audio.data)

VolumeAdjust

VolumeAdjust

Bases: AudioEffect

Changes audio volume within a time range without affecting video frames.

Source code in src/videopython/base/effects.py
class VolumeAdjust(AudioEffect):
    """Changes audio volume within a time range without affecting video frames."""

    def __init__(self, volume: float = 1.0, ramp_duration: float = 0.0):
        """Initialize volume adjustment effect.

        Args:
            volume: Volume multiplier. 0.0 = silence, 1.0 = original level,
                2.0 = twice as loud (may clip).
            ramp_duration: Seconds to smoothly ramp volume at the start and end
                of the window, preventing audible clicks.
        """
        if volume < 0:
            raise ValueError(f"volume must be >= 0, got {volume}")
        if ramp_duration < 0:
            raise ValueError(f"ramp_duration must be >= 0, got {ramp_duration}")
        self.volume = volume
        self.ramp_duration = ramp_duration

    def _apply_audio(self, audio, start: float, stop: float, fps: float):
        if audio is None or audio.is_silent:
            return audio

        sample_rate = audio.metadata.sample_rate
        start_sample = round(start * sample_rate)
        end_sample = min(round(stop * sample_rate), len(audio.data))
        n_samples = end_sample - start_sample

        # Build volume envelope
        envelope = np.full(n_samples, self.volume, dtype=np.float32)

        if self.ramp_duration > 0:
            ramp_samples = min(round(self.ramp_duration * sample_rate), n_samples // 2)
            if ramp_samples > 0:
                # Ramp from 1.0 to target volume at start
                t = np.linspace(0, 1, ramp_samples, dtype=np.float32)
                ramp_in = 1.0 + (self.volume - 1.0) * np.sqrt(t)
                envelope[:ramp_samples] = ramp_in

                # Ramp from target volume back to 1.0 at end
                t = np.linspace(1, 0, ramp_samples, dtype=np.float32)
                ramp_out = 1.0 + (self.volume - 1.0) * np.sqrt(t)
                envelope[-ramp_samples:] = ramp_out

        if audio.data.ndim == 1:
            audio.data[start_sample:end_sample] *= envelope
        else:
            audio.data[start_sample:end_sample] *= envelope[:, np.newaxis]
        np.clip(audio.data, -1.0, 1.0, out=audio.data)

        return audio

__init__

__init__(volume: float = 1.0, ramp_duration: float = 0.0)

Initialize volume adjustment effect.

Parameters:

Name Type Description Default
volume float

Volume multiplier. 0.0 = silence, 1.0 = original level, 2.0 = twice as loud (may clip).

1.0
ramp_duration float

Seconds to smoothly ramp volume at the start and end of the window, preventing audible clicks.

0.0
Source code in src/videopython/base/effects.py
def __init__(self, volume: float = 1.0, ramp_duration: float = 0.0):
    """Initialize volume adjustment effect.

    Args:
        volume: Volume multiplier. 0.0 = silence, 1.0 = original level,
            2.0 = twice as loud (may clip).
        ramp_duration: Seconds to smoothly ramp volume at the start and end
            of the window, preventing audible clicks.
    """
    if volume < 0:
        raise ValueError(f"volume must be >= 0, got {volume}")
    if ramp_duration < 0:
        raise ValueError(f"ramp_duration must be >= 0, got {ramp_duration}")
    self.volume = volume
    self.ramp_duration = ramp_duration

TextOverlay

TextOverlay

Bases: Effect

Draws text on video frames, with auto word-wrap and optional background box.

Source code in src/videopython/base/effects.py
class TextOverlay(Effect):
    """Draws text on video frames, with auto word-wrap and optional background box."""

    supports_streaming: ClassVar[bool] = True

    def __init__(
        self,
        text: str,
        position: tuple[float, float] = (0.5, 0.9),
        font_size: int = 48,
        text_color: tuple[int, int, int] = (255, 255, 255),
        background_color: tuple[int, int, int, int] | None = (0, 0, 0, 160),
        background_padding: int = 12,
        max_width: float = 0.8,
        anchor: Literal["center", "top_left", "top_center", "bottom_center", "bottom_left", "bottom_right"] = "center",
        font_filename: str | None = None,
    ):
        """Initialize text overlay effect.

        Args:
            text: The string to display. Use \\n for line breaks.
            position: Where to place the text as normalized (x, y) coordinates.
                (0, 0) = top-left corner, (1, 1) = bottom-right corner.
            font_size: Font size in pixels.
            text_color: Text color as [R, G, B], each 0-255.
            background_color: Background box color as [R, G, B, A] (0-255),
                or null to disable the background.
            background_padding: Padding in pixels between text and background edge.
            max_width: Maximum text width as a fraction of frame width (0-1).
                Text longer than this wraps to the next line.
            anchor: Which point of the text box sits at the position coordinate.
            font_filename: Path to a .ttf font file, or None for the default font.
        """
        if not text:
            raise ValueError("text must not be empty")
        if not 0.0 <= position[0] <= 1.0 or not 0.0 <= position[1] <= 1.0:
            raise ValueError("position values must be in range [0, 1]")
        if font_size < 1:
            raise ValueError(f"font_size must be >= 1, got {font_size}")
        if not 0.0 < max_width <= 1.0:
            raise ValueError(f"max_width must be in range (0, 1], got {max_width}")

        self.text = text
        self.position = position
        self.font_size = font_size
        self.text_color = text_color
        self.background_color = background_color
        self.background_padding = background_padding
        self.max_width = max_width
        self.anchor = anchor
        self.font_filename = font_filename
        self._rendered: np.ndarray | None = None

    def _get_font(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        if self.font_filename:
            return ImageFont.truetype(self.font_filename, self.font_size)
        try:
            return ImageFont.truetype("DejaVuSans.ttf", self.font_size)
        except OSError:
            return ImageFont.load_default()

    def _wrap_text(self, text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_px: int) -> str:
        """Word-wrap text to fit within max_px width."""
        lines: list[str] = []
        for paragraph in text.split("\n"):
            words = paragraph.split()
            if not words:
                lines.append("")
                continue
            current = words[0]
            for word in words[1:]:
                test = current + " " + word
                bbox = font.getbbox(test)
                if bbox[2] - bbox[0] <= max_px:
                    current = test
                else:
                    lines.append(current)
                    current = word
            lines.append(current)
        return "\n".join(lines)

    def _render_text_image(self, frame_width: int, frame_height: int) -> np.ndarray:
        """Render text to an RGBA numpy array sized for the given frame dimensions."""
        font = self._get_font()
        max_px = int(self.max_width * frame_width)
        wrapped = self._wrap_text(self.text, font, max_px)

        # Measure text bounds
        temp_img = Image.new("RGBA", (1, 1))
        temp_draw = ImageDraw.Draw(temp_img)
        bbox = temp_draw.multiline_textbbox((0, 0), wrapped, font=font)
        text_w = bbox[2] - bbox[0]
        text_h = bbox[3] - bbox[1]

        pad = self.background_padding
        img_w = text_w + 2 * pad
        img_h = text_h + 2 * pad

        # Create RGBA image
        if self.background_color is not None:
            bg = self.background_color
            img = Image.new("RGBA", (img_w, img_h), bg)
        else:
            img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))

        draw = ImageDraw.Draw(img)
        draw.multiline_text((pad - bbox[0], pad - bbox[1]), wrapped, font=font, fill=(*self.text_color, 255))

        return np.array(img, dtype=np.uint8)

    def _compute_position(self, frame_width: int, frame_height: int, img_w: int, img_h: int) -> tuple[int, int]:
        """Compute top-left pixel position based on normalized position and anchor."""
        px = int(self.position[0] * frame_width)
        py = int(self.position[1] * frame_height)

        if self.anchor == "center":
            return px - img_w // 2, py - img_h // 2
        elif self.anchor == "top_left":
            return px, py
        elif self.anchor == "top_center":
            return px - img_w // 2, py
        elif self.anchor == "bottom_center":
            return px - img_w // 2, py - img_h
        elif self.anchor == "bottom_left":
            return px, py - img_h
        elif self.anchor == "bottom_right":
            return px - img_w, py - img_h
        return px - img_w // 2, py - img_h // 2

    def streaming_init(self, total_frames: int, fps: float, width: int, height: int) -> None:
        if self._rendered is None:
            self._rendered = self._render_text_image(width, height)
        oh, ow = self._rendered.shape[:2]
        x, y = self._compute_position(width, height, ow, oh)
        src_x = max(0, -x)
        src_y = max(0, -y)
        dst_x = max(0, x)
        dst_y = max(0, y)
        paste_w = min(ow - src_x, width - dst_x)
        paste_h = min(oh - src_y, height - dst_y)
        if paste_w <= 0 or paste_h <= 0:
            self._stream_noop = True
            return
        self._stream_noop = False
        overlay_region = self._rendered[src_y : src_y + paste_h, src_x : src_x + paste_w]
        self._stream_alpha = overlay_region[:, :, 3:4].astype(np.float32) / 255.0
        self._stream_rgb = overlay_region[:, :, :3].astype(np.float32)
        self._stream_dst = (dst_y, dst_x, paste_h, paste_w)

    def process_frame(self, frame: np.ndarray, frame_index: int) -> np.ndarray:
        if self._stream_noop:
            return frame
        dy, dx, ph, pw = self._stream_dst
        region = frame[dy : dy + ph, dx : dx + pw]
        blended = (
            self._stream_rgb * self._stream_alpha + region.astype(np.float32) * (1.0 - self._stream_alpha)
        ).astype(np.uint8)
        frame[dy : dy + ph, dx : dx + pw] = blended
        return frame

    def _apply(self, video: Video) -> Video:
        frame_h, frame_w = video.frame_shape[:2]

        if self._rendered is None:
            self._rendered = self._render_text_image(frame_w, frame_h)

        overlay_rgba = self._rendered
        oh, ow = overlay_rgba.shape[:2]
        x, y = self._compute_position(frame_w, frame_h, ow, oh)

        # Clamp to frame bounds
        src_x = max(0, -x)
        src_y = max(0, -y)
        dst_x = max(0, x)
        dst_y = max(0, y)
        paste_w = min(ow - src_x, frame_w - dst_x)
        paste_h = min(oh - src_y, frame_h - dst_y)

        if paste_w <= 0 or paste_h <= 0:
            return video

        overlay_region = overlay_rgba[src_y : src_y + paste_h, src_x : src_x + paste_w]
        alpha = overlay_region[:, :, 3:4].astype(np.float32) / 255.0
        overlay_rgb = overlay_region[:, :, :3].astype(np.float32)

        log("Applying text overlay...")
        for frame in progress_iter(video.frames, desc="Text overlay"):
            region = frame[dst_y : dst_y + paste_h, dst_x : dst_x + paste_w]
            blended = (overlay_rgb * alpha + region.astype(np.float32) * (1.0 - alpha)).astype(np.uint8)
            frame[dst_y : dst_y + paste_h, dst_x : dst_x + paste_w] = blended

        return video

__init__

__init__(
    text: str,
    position: tuple[float, float] = (0.5, 0.9),
    font_size: int = 48,
    text_color: tuple[int, int, int] = (255, 255, 255),
    background_color: tuple[int, int, int, int] | None = (
        0,
        0,
        0,
        160,
    ),
    background_padding: int = 12,
    max_width: float = 0.8,
    anchor: Literal[
        "center",
        "top_left",
        "top_center",
        "bottom_center",
        "bottom_left",
        "bottom_right",
    ] = "center",
    font_filename: str | None = None,
)

Initialize text overlay effect.

Parameters:

Name Type Description Default
text str

The string to display. Use \n for line breaks.

required
position tuple[float, float]

Where to place the text as normalized (x, y) coordinates. (0, 0) = top-left corner, (1, 1) = bottom-right corner.

(0.5, 0.9)
font_size int

Font size in pixels.

48
text_color tuple[int, int, int]

Text color as [R, G, B], each 0-255.

(255, 255, 255)
background_color tuple[int, int, int, int] | None

Background box color as [R, G, B, A] (0-255), or null to disable the background.

(0, 0, 0, 160)
background_padding int

Padding in pixels between text and background edge.

12
max_width float

Maximum text width as a fraction of frame width (0-1). Text longer than this wraps to the next line.

0.8
anchor Literal['center', 'top_left', 'top_center', 'bottom_center', 'bottom_left', 'bottom_right']

Which point of the text box sits at the position coordinate.

'center'
font_filename str | None

Path to a .ttf font file, or None for the default font.

None
Source code in src/videopython/base/effects.py
def __init__(
    self,
    text: str,
    position: tuple[float, float] = (0.5, 0.9),
    font_size: int = 48,
    text_color: tuple[int, int, int] = (255, 255, 255),
    background_color: tuple[int, int, int, int] | None = (0, 0, 0, 160),
    background_padding: int = 12,
    max_width: float = 0.8,
    anchor: Literal["center", "top_left", "top_center", "bottom_center", "bottom_left", "bottom_right"] = "center",
    font_filename: str | None = None,
):
    """Initialize text overlay effect.

    Args:
        text: The string to display. Use \\n for line breaks.
        position: Where to place the text as normalized (x, y) coordinates.
            (0, 0) = top-left corner, (1, 1) = bottom-right corner.
        font_size: Font size in pixels.
        text_color: Text color as [R, G, B], each 0-255.
        background_color: Background box color as [R, G, B, A] (0-255),
            or null to disable the background.
        background_padding: Padding in pixels between text and background edge.
        max_width: Maximum text width as a fraction of frame width (0-1).
            Text longer than this wraps to the next line.
        anchor: Which point of the text box sits at the position coordinate.
        font_filename: Path to a .ttf font file, or None for the default font.
    """
    if not text:
        raise ValueError("text must not be empty")
    if not 0.0 <= position[0] <= 1.0 or not 0.0 <= position[1] <= 1.0:
        raise ValueError("position values must be in range [0, 1]")
    if font_size < 1:
        raise ValueError(f"font_size must be >= 1, got {font_size}")
    if not 0.0 < max_width <= 1.0:
        raise ValueError(f"max_width must be in range (0, 1], got {max_width}")

    self.text = text
    self.position = position
    self.font_size = font_size
    self.text_color = text_color
    self.background_color = background_color
    self.background_padding = background_padding
    self.max_width = max_width
    self.anchor = anchor
    self.font_filename = font_filename
    self._rendered: np.ndarray | None = None