Skip to content

suggestions

sleap_io.model.suggestions

Data module for suggestions.

Classes:

Name Description
SuggestionFrame

Data structure for a single frame of suggestions.

Video

Video class used by sleap to represent videos and data associated with them.

Attributes:

Name Type Description
__cached__

str(object='') -> str

__doc__

str(object='') -> str

__file__

str(object='') -> str

__name__

str(object='') -> str

__package__

str(object='') -> str

__cached__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/model/__pycache__/suggestions.cpython-313.pyc' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__doc__ = 'Data module for suggestions.' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__file__ = '/home/runner/work/sleap-io/sleap-io/sleap_io/model/suggestions.py' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__name__ = 'sleap_io.model.suggestions' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__package__ = 'sleap_io.model' module-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

SuggestionFrame

Data structure for a single frame of suggestions.

Attributes:

Name Type Description
video

The video associated with the frame.

frame_idx

The index of the frame in the video.

metadata

Dictionary containing additional metadata that is not explicitly represented in the data model. This is used to store arbitrary metadata such as the "group" key when reading/writing SLP files.

Methods:

Name Description
__eq__

Method generated by attrs for class SuggestionFrame.

__init__

Method generated by attrs for class SuggestionFrame.

__repr__

Method generated by attrs for class SuggestionFrame.

__setattr__

Method generated by attrs for class SuggestionFrame.

Source code in sleap_io/model/suggestions.py
@attrs.define(auto_attribs=True)
class SuggestionFrame:
    """Data structure for a single frame of suggestions.

    Attributes:
        video: The video associated with the frame.
        frame_idx: The index of the frame in the video.
        metadata: Dictionary containing additional metadata that is not explicitly
            represented in the data model. This is used to store arbitrary metadata
            such as the "group" key when reading/writing SLP files.
    """

    video: Video
    frame_idx: int = field(converter=int)
    metadata: dict[str, any] = attrs.field(factory=dict)

__annotations__ = {'video': 'Video', 'frame_idx': 'int', 'metadata': 'dict[str, any]'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = True class-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=True, added_eq=True, added_ordering=False, hashability=<Hashability.UNHASHABLE: 'unhashable'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = 'Data structure for a single frame of suggestions.\n\nAttributes:\n video: The video associated with the frame.\n frame_idx: The index of the frame in the video.\n metadata: Dictionary containing additional metadata that is not explicitly\n represented in the data model. This is used to store arbitrary metadata\n such as the "group" key when reading/writing SLP files.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 11 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('video', 'frame_idx', 'metadata') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.suggestions' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('video', 'frame_idx', 'metadata', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = () class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

__eq__(other)

Method generated by attrs for class SuggestionFrame.

Source code in sleap_io/model/suggestions.py
    video: The video associated with the frame.
    frame_idx: The index of the frame in the video.
    metadata: Dictionary containing additional metadata that is not explicitly
        represented in the data model. This is used to store arbitrary metadata
        such as the "group" key when reading/writing SLP files.
"""

video: Video

__init__(video, frame_idx, metadata=NOTHING)

Method generated by attrs for class SuggestionFrame.

Source code in sleap_io/model/suggestions.py
frame_idx: int = field(converter=int)
metadata: dict[str, any] = attrs.field(factory=dict)

__repr__()

Method generated by attrs for class SuggestionFrame.

Source code in sleap_io/model/suggestions.py
"""Data module for suggestions."""

from __future__ import annotations

import attrs
from attrs import field

from sleap_io.model.video import Video


@attrs.define(auto_attribs=True)
class SuggestionFrame:
    """Data structure for a single frame of suggestions.

    Attributes:

__setattr__(name, val)

Method generated by attrs for class SuggestionFrame.

Video

Video class used by sleap to represent videos and data associated with them.

This class is used to store information regarding a video and its components. It is used to store the video's filename, shape, and the video's backend.

To create a Video object, use the from_filename method which will select the backend appropriately.

Attributes:

Name Type Description
filename

The filename(s) of the video. Supported extensions: "mp4", "avi", "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif", "tiff", "bmp", "seq". If the filename is a list, a list of image filenames are expected. If filename is a folder, it will be searched for images.

backend

An object that implements the basic methods for reading and manipulating frames of a specific video type.

backend_metadata

A dictionary of metadata specific to the backend. This is useful for storing metadata that requires an open backend (e.g., shape information) without having access to the video file itself.

source_video

The source video object if this is a proxy video. This is present when the video contains an embedded subset of frames from another video.

open_backend

Whether to open the backend when the video is available. If True (the default), the backend will be automatically opened if the video exists. Set this to False when you want to manually open the backend, or when the you know the video file does not exist and you want to avoid trying to open the file.

_exists_cache

Per-instance TTL cache for the result of exists() when the filename is a remote URL. Keyed by (filename, dataset) and storing (exists_bool, monotonic_timestamp). This avoids issuing a network probe on every call (e.g. from the is_open property, which GUIs poll on each render). The TTL defaults to 60 seconds and can be overridden via the SLEAP_IO_EXISTS_TTL environment variable. The cache is cleared on replace_filename.

Notes

Instances of this class are hashed by identity, not by value. This means that two Video instances with the same attributes will NOT be considered equal in a set or dict.

Media Video Plugin Support

For media files (mp4, avi, etc.), the following plugins are supported: - "opencv": Uses OpenCV (cv2) for video reading - "FFMPEG": Uses imageio-ffmpeg for video reading - "pyav": Uses PyAV for video reading

Plugin aliases (case-insensitive): - opencv: "opencv", "cv", "cv2", "ocv" - FFMPEG: "FFMPEG", "ffmpeg", "imageio-ffmpeg", "imageio_ffmpeg" - pyav: "pyav", "av"

Plugin selection priority: 1. Explicitly specified plugin parameter 2. Backend metadata plugin value 3. Global default (set via sio.set_default_video_plugin) 4. Auto-detection based on available packages

See Also

VideoBackend: The backend interface for reading video data. sleap_io.set_default_video_plugin: Set global default plugin. sleap_io.get_default_video_plugin: Get current default plugin.

Methods:

Name Description
__attrs_post_init__

Post init syntactic sugar.

__deepcopy__

Deep copy the video object.

__getitem__

Return the frames of the video at the given indices.

__init__

Method generated by attrs for class Video.

__len__

Return the length of the video as the number of frames.

__repr__

Informal string representation (for print or format).

__str__

Informal string representation (for print or format).

apply_crop

Bake this video's virtual crop into a new physical video file.

close

Close the video backend.

crop

Return a virtual, on-read cropped view of this video.

deduplicate_with

Create a new video with duplicate images removed.

exists

Check if the video file exists and is accessible.

frame_to_seconds

Convert a frame index to timestamp in seconds.

from_crop

Open video (path or Video) and return a virtual crop.

from_filename

Create a Video from a filename.

has_overlapping_images

Check if this video has overlapping images with another video.

matches_content

Check if this video has the same content as another video.

matches_path

Check if this video has the same path as another video.

matches_shape

Check if this video has the same shape as another video.

merge_with

Merge another video's images into this one.

open

Open the video backend for reading.

replace_filename

Update the filename of the video, optionally opening the backend.

save

Save video frames to a new video file.

seconds_to_frame

Convert a timestamp in seconds to frame index.

set_video_plugin

Set the video plugin and reopen the video.

to_crop_coords

Map source-frame (x, y) into this video's cropped frame.

to_source_coords

Map cropped-frame (x, y) back to source-frame coordinates.

Source code in sleap_io/model/video.py
@attrs.define(eq=False)
class Video:
    """`Video` class used by sleap to represent videos and data associated with them.

    This class is used to store information regarding a video and its components.
    It is used to store the video's `filename`, `shape`, and the video's `backend`.

    To create a `Video` object, use the `from_filename` method which will select the
    backend appropriately.

    Attributes:
        filename: The filename(s) of the video. Supported extensions: "mp4", "avi",
            "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif",
            "tiff", "bmp", "seq". If the filename is a list, a list of image filenames
            are expected. If filename is a folder, it will be searched for images.
        backend: An object that implements the basic methods for reading and
            manipulating frames of a specific video type.
        backend_metadata: A dictionary of metadata specific to the backend. This is
            useful for storing metadata that requires an open backend (e.g., shape
            information) without having access to the video file itself.
        source_video: The source video object if this is a proxy video. This is present
            when the video contains an embedded subset of frames from another video.
        open_backend: Whether to open the backend when the video is available. If `True`
            (the default), the backend will be automatically opened if the video exists.
            Set this to `False` when you want to manually open the backend, or when the
            you know the video file does not exist and you want to avoid trying to open
            the file.
        _exists_cache: Per-instance TTL cache for the result of `exists()` when the
            `filename` is a remote URL. Keyed by `(filename, dataset)` and storing
            `(exists_bool, monotonic_timestamp)`. This avoids issuing a network probe
            on every call (e.g. from the `is_open` property, which GUIs poll on each
            render). The TTL defaults to 60 seconds and can be overridden via the
            `SLEAP_IO_EXISTS_TTL` environment variable. The cache is cleared on
            `replace_filename`.

    Notes:
        Instances of this class are hashed by identity, not by value. This means that
        two `Video` instances with the same attributes will NOT be considered equal in a
        set or dict.

    Media Video Plugin Support:
        For media files (mp4, avi, etc.), the following plugins are supported:
        - "opencv": Uses OpenCV (cv2) for video reading
        - "FFMPEG": Uses imageio-ffmpeg for video reading
        - "pyav": Uses PyAV for video reading

        Plugin aliases (case-insensitive):
        - opencv: "opencv", "cv", "cv2", "ocv"
        - FFMPEG: "FFMPEG", "ffmpeg", "imageio-ffmpeg", "imageio_ffmpeg"
        - pyav: "pyav", "av"

        Plugin selection priority:
        1. Explicitly specified plugin parameter
        2. Backend metadata plugin value
        3. Global default (set via sio.set_default_video_plugin)
        4. Auto-detection based on available packages

    See Also:
        VideoBackend: The backend interface for reading video data.
        sleap_io.set_default_video_plugin: Set global default plugin.
        sleap_io.get_default_video_plugin: Get current default plugin.
    """

    filename: str | list[str]
    backend: VideoBackend | None = None
    backend_metadata: dict[str, any] = attrs.field(factory=dict)
    source_video: "Video | None" = None
    open_backend: bool = True
    _exists_cache: dict[tuple[str, str | None], tuple[bool, float]] = attrs.field(
        init=False, factory=dict, repr=False, eq=False
    )
    # URL auth context, threaded in by `make_video` for remote loads. Persisted
    # on the Video (not just the backend) so existence probes and a later
    # `open()` reconstruction stay authenticated after the backend is closed.
    _url_headers: dict[str, str] | None = attrs.field(
        init=False, default=None, repr=False, eq=False
    )
    _url_stream_mode: str = attrs.field(
        init=False, default="blockcache", repr=False, eq=False
    )

    EXTS = MediaVideo.EXTS + HDF5Video.EXTS + ImageVideo.EXTS + ("seq",)

    def _backend_url_headers(self) -> dict[str, str] | None:
        """Return the HTTP headers to authenticate remote existence probes.

        Prefers the URL auth context stored on this `Video` (set by `make_video`
        at load time); falls back to the live backend's headers when present.
        Returns `None` for local files and unauthenticated URLs.
        """
        if self._url_headers is not None:
            return self._url_headers
        if isinstance(self.backend, HDF5Video):
            return getattr(self.backend, "_url_headers", None)
        return None

    @property
    def original_video(self) -> "Video | None":
        """The root video in the provenance chain.

        For embedded videos, this returns the ultimate source video by
        traversing the source_video chain. Returns None if this video
        has no source_video (i.e., it IS an original).

        This property is computed by following the source_video chain to find
        the root. For a single-level embedding (A embeds from B), original_video
        returns B. For multi-level embedding (A <- B <- C), it returns C.
        """
        if self.source_video is None:
            return None  # This IS the original

        # Traverse to root
        v = self.source_video
        while v.source_video is not None:
            v = v.source_video
        return v

    def __attrs_post_init__(self):
        """Post init syntactic sugar."""
        if self.open_backend and self.backend is None and self.exists():
            try:
                self.open()
            except Exception:
                # If we can't open the backend, just ignore it for now so we don't
                # prevent the user from building the Video object entirely.
                pass

    def __deepcopy__(self, memo):
        """Deep copy the video object."""
        if id(self) in memo:
            return memo[id(self)]

        reopen = False
        if self.is_open:
            reopen = True
            self.close()

        new_video = Video(
            filename=self.filename,
            backend=None,
            backend_metadata=self.backend_metadata.copy(),
            source_video=self.source_video,
            open_backend=self.open_backend,
        )

        memo[id(self)] = new_video

        if reopen:
            self.open()

        return new_video

    @classmethod
    def from_filename(
        cls,
        filename: str | list[str],
        dataset: str | None = None,
        grayscale: bool | None = None,
        keep_open: bool = True,
        source_video: "Video | None" = None,
        **kwargs,
    ) -> VideoBackend:
        """Create a Video from a filename.

        Args:
            filename: The filename(s) of the video. Supported extensions: "mp4", "avi",
                "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif",
                "tiff", "bmp". If the filename is a list, a list of image filenames are
                expected. If filename is a folder, it will be searched for images.
            dataset: Name of dataset in HDF5 file.
            grayscale: Whether to force grayscale. If None, autodetect on first frame
                load.
            keep_open: Whether to keep the video reader open between calls to read
                frames. If False, will close the reader after each call. If True (the
                default), it will keep the reader open and cache it for subsequent calls
                which may enhance the performance of reading multiple frames.
            source_video: The source video object if this is a proxy video. This is
                present when the video contains an embedded subset of frames from
                another video.
            **kwargs: Additional backend-specific arguments passed to
                VideoBackend.from_filename. See VideoBackend.from_filename for supported
                arguments.

        Returns:
            Video instance with the appropriate backend instantiated.
        """
        backend = VideoBackend.from_filename(
            filename,
            dataset=dataset,
            grayscale=grayscale,
            keep_open=keep_open,
            **kwargs,
        )
        # If filename is a directory, VideoBackend.from_filename will expand it
        # to a list of paths to images contained within the directory. In this
        # case we want to use the expanded list as filename
        return cls(
            filename=backend.filename,
            backend=backend,
            source_video=source_video,
        )

    def crop(
        self,
        crop: tuple[int, int, int, int] | None = None,
        *,
        bbox: tuple[float, float, float, float] | None = None,
        roi: object | None = None,
        center: tuple[float, float] | None = None,
        size: tuple[int, int] | None = None,
        margin: int = 0,
        fill: int | tuple[int, ...] = 0,
        share_decode: bool = True,
    ) -> "Video":
        """Return a virtual, on-read cropped view of this video.

        Exactly one region spec must be given: ``crop`` (explicit
        ``(x1, y1, x2, y2)`` rect), ``bbox``, ``roi`` (its axis-aligned bounds +
        ``margin``), or (``center``, ``size``) for a fixed-size centered/
        centroid-following window. The returned ``Video`` shares no pixels with
        this one; frames are decoded on read and cropped (byte-identical to
        :func:`sleap_io.transform.frame.crop_frame`). Out-of-bounds regions are
        pad-filled with ``fill`` (never clamped), so the output shape is always
        exactly ``(y2 - y1, x2 - x1)``.

        The crop composes (FLATTENS when fills agree and the region is in-bounds)
        with any existing crop on this video via
        :meth:`CropVideoBackend.wrap`. ``source_video`` is set to this video for
        provenance. When ``share_decode`` (the default), the new crop reuses this
        video's backend instance as the shared inner so a mosaic of tiles over
        one file decodes each source frame once; in that case the new tile does
        NOT own the shared decoder (this video does).

        Args:
            crop: Explicit crop region ``(x1, y1, x2, y2)``, ``x2``/``y2``
                exclusive.
            bbox: A bounding box ``(x1, y1, x2, y2)``; bounds may be float.
            roi: Any object exposing axis-aligned ``.bounds`` as
                ``(minx, miny, maxx, maxy)`` (e.g. a shapely geometry).
            center: Window center ``(cx, cy)`` (used with ``size``).
            size: Fixed output ``(width, height)`` (used with ``center``).
            margin: Pixels added around the ``roi`` bounds on every side.
            fill: Fill value for out-of-bounds regions.
            share_decode: If ``True`` (the default), reuse this video's backend
                as the shared inner so tiles decode each frame once; the new tile
                does not own the shared decoder.

        Returns:
            A new ``Video`` exposing the cropped view.
        """
        from sleap_io.io.video_reading import CropVideoBackend

        rect = _resolve_crop_rect(crop, bbox, roi, center, size, margin)
        if self.backend is None and self.open_backend:
            self.open()
        if self.backend is None:
            raise ValueError(
                "Cannot crop a video with no open backend. Open it first (set "
                "open_backend=True or call .open()) before cropping."
            )
        inner = self.backend
        cropped_backend = CropVideoBackend.wrap(
            inner=inner, crop=rect, fill=fill, owns_inner=not share_decode
        )

        cropped = Video(
            filename=self.filename,
            backend=cropped_backend,
            source_video=self,
            open_backend=self.open_backend,
        )

        x1, y1, x2, y2 = cropped_backend.crop
        src_shape = self.shape
        cropped.backend_metadata = {
            **self.backend_metadata,
            "shape": (src_shape[0], y2 - y1, x2 - x1, src_shape[3])
            if src_shape is not None
            else None,
            # The uncropped source shape, so a closed re-serialize keeps videos_json
            # describing the full frame even without a live source_video (D-120/DI-2).
            "source_shape": list(src_shape) if src_shape is not None else None,
            # COMPOSED source rect from wrap (D-120): keeps open/closed crop keys
            # identical and root-canonical, and survives close()->open().
            "crop": list(cropped_backend.crop),
            "crop_fill": cropped_backend.fill,
        }
        return cropped

    @classmethod
    def from_crop(
        cls,
        video: "str | Path | Video",
        crop: tuple[int, int, int, int] | None = None,
        *,
        bbox: tuple[float, float, float, float] | None = None,
        roi: object | None = None,
        center: tuple[float, float] | None = None,
        size: tuple[int, int] | None = None,
        margin: int = 0,
        fill: int | tuple[int, ...] = 0,
        share_decode: bool = True,
        **kwargs,
    ) -> "Video":
        """Open ``video`` (path or ``Video``) and return a virtual crop.

        Accepts the same region specs as :meth:`crop` (``crop``/``bbox``/``roi``/
        ``center``+``size``); extra keyword arguments are forwarded to
        :meth:`from_filename` when ``video`` is a path (ignored when it is already
        a ``Video``).

        Args:
            video: A path/filename to open, or an existing ``Video`` to crop.
            crop: Explicit crop region ``(x1, y1, x2, y2)``, ``x2``/``y2`` exclusive.
            bbox: A bounding box ``(x1, y1, x2, y2)``; bounds may be float.
            roi: An object exposing axis-aligned ``.bounds`` (e.g. a shapely
                geometry); ``margin`` is applied around it.
            center: Window center ``(cx, cy)`` (with ``size``).
            size: Fixed output ``(width, height)`` (with ``center``).
            margin: Pixels added around the ``roi`` bounds on every side.
            fill: Fill value for out-of-bounds regions.
            share_decode: If ``True`` (default), reuse the source decoder.
            **kwargs: Forwarded to :meth:`from_filename` for a path input.

        Returns:
            A new ``Video`` exposing the cropped view.
        """
        if isinstance(video, (str, Path)):
            video = cls.from_filename(video, **kwargs)
        return video.crop(
            crop,
            bbox=bbox,
            roi=roi,
            center=center,
            size=size,
            margin=margin,
            fill=fill,
            share_decode=share_decode,
        )

    def _crop_tuple(self) -> tuple[int, int, int, int] | None:
        """Return this video's crop rect ``(x1, y1, x2, y2)`` or ``None``.

        Reads ``backend.crop`` when the backend is a ``CropVideoBackend`` (open
        path), else ``backend_metadata["crop"]`` (closed path), else ``None``
        (uncropped).
        """
        from sleap_io.io.video_reading import CropVideoBackend

        if isinstance(self.backend, CropVideoBackend):
            return tuple(self.backend.crop)
        crop = self.backend_metadata.get("crop")
        return tuple(crop) if crop is not None else None

    def _crop_fill(self) -> int | tuple[int, ...]:
        """Return this video's crop fill value (open: backend; closed: metadata).

        Returns ``0`` for an uncropped video. Mirrors :meth:`_crop_tuple`.
        """
        from sleap_io.io.video_reading import CropVideoBackend

        if isinstance(self.backend, CropVideoBackend):
            return self.backend.fill
        return self.backend_metadata.get("crop_fill", 0)

    @property
    def is_cropped(self) -> bool:
        """Whether this video is a virtual crop of another video."""
        return self._crop_tuple() is not None

    @property
    def crop_rect(self) -> tuple[int, int, int, int] | None:
        """Crop rect ``(x1, y1, x2, y2)`` in source coords, or ``None`` if uncropped."""
        return self._crop_tuple()

    @property
    def crop_fill(self) -> int | tuple[int, ...]:
        """The out-of-bounds fill value for this video's crop (``0`` if uncropped)."""
        return self._crop_fill()

    def to_crop_coords(self, points: np.ndarray) -> np.ndarray:
        """Map source-frame ``(x, y)`` into this video's cropped frame.

        Args:
            points: Coordinate array of shape ``(..., 2)``. NaN values are
                preserved.

        Returns:
            Coordinates translated into the cropped frame. If this video is not
            cropped, a copy of ``points`` is returned unchanged.
        """
        crop = self._crop_tuple()
        return points.copy() if crop is None else crop_points(points, crop)

    def to_source_coords(self, points: np.ndarray) -> np.ndarray:
        """Map cropped-frame ``(x, y)`` back to source-frame coordinates.

        Inverse of :meth:`to_crop_coords`.

        Args:
            points: Coordinate array of shape ``(..., 2)``. NaN values are
                preserved.

        Returns:
            Coordinates translated back to source coordinates. If this video is
            not cropped, a copy of ``points`` is returned unchanged.
        """
        crop = self._crop_tuple()
        return points.copy() if crop is None else uncrop_points(points, crop)

    @property
    def shape(self) -> tuple[int, int, int, int] | None:
        """Return the shape of the video as (num_frames, height, width, channels).

        If the video backend is not set or it cannot determine the shape of the video,
        this will return None.
        """
        return self._get_shape()

    def _get_shape(self) -> tuple[int, int, int, int] | None:
        """Return the shape of the video as (num_frames, height, width, channels).

        This suppresses errors related to querying the backend for the video shape, such
        as when it has not been set or when the video file is not found.
        """
        try:
            return self.backend.shape
        except Exception:
            if "shape" in self.backend_metadata:
                return self.backend_metadata["shape"]
            return None

    @property
    def grayscale(self) -> bool | None:
        """Return whether the video is grayscale.

        If the video backend is not set or it cannot determine whether the video is
        grayscale, this will return None.
        """
        shape = self.shape
        if shape is not None:
            return shape[-1] == 1
        else:
            grayscale = None
            if "grayscale" in self.backend_metadata:
                grayscale = self.backend_metadata["grayscale"]
            return grayscale

    @grayscale.setter
    def grayscale(self, value: bool):
        """Set the grayscale value and adjust the backend."""
        if self.backend is not None:
            self.backend.grayscale = value
            self.backend._cached_shape = None

        self.backend_metadata["grayscale"] = value

    @property
    def fps(self) -> float | None:
        """Return the frames per second of the video.

        For MediaVideo backends, this reads FPS from the video container metadata.
        For other backends (ImageVideo, HDF5Video, TiffVideo), this returns the
        explicitly set value or None if not set.

        Returns:
            The FPS if known, or None if unavailable/unknown.
        """
        if self.backend is not None:
            return self.backend.fps
        return self.backend_metadata.get("fps")

    @fps.setter
    def fps(self, value: float | None):
        """Set the frames per second.

        Args:
            value: Frames per second. Must be positive if not None.

        Raises:
            ValueError: If value is not positive.

        Notes:
            For MediaVideo backends, setting FPS overrides the value from container
            metadata. For other backends, this sets the FPS directly.
        """
        if value is not None and value <= 0:
            raise ValueError(f"FPS must be positive, got {value}")

        if self.backend is not None:
            self.backend.fps = value
        self.backend_metadata["fps"] = value

    def frame_to_seconds(self, frame_idx: int) -> float | None:
        """Convert a frame index to timestamp in seconds.

        Args:
            frame_idx: Zero-indexed frame number.

        Returns:
            Time in seconds, or None if FPS is unknown.

        Notes:
            This assumes constant frame rate. For variable frame rate videos,
            the returned timestamp may be approximate.
        """
        if self.fps is None or self.fps <= 0:
            return None
        return frame_idx / self.fps

    def seconds_to_frame(self, seconds: float) -> int | None:
        """Convert a timestamp in seconds to frame index.

        Args:
            seconds: Time in seconds from video start.

        Returns:
            Zero-indexed frame number (rounded down), or None if FPS unknown.
        """
        if self.fps is None or self.fps <= 0:
            return None
        return int(seconds * self.fps)

    def __len__(self) -> int:
        """Return the length of the video as the number of frames."""
        shape = self.shape
        return 0 if shape is None else shape[0]

    def __repr__(self) -> str:
        """Informal string representation (for print or format)."""
        dataset = (
            f"dataset={self.backend.dataset}, "
            if getattr(self.backend, "dataset", "")
            else ""
        )
        return (
            "Video("
            f'filename="{self.filename}", '
            f"shape={self.shape}, "
            f"{dataset}"
            f"backend={type(self.backend).__name__}"
            ")"
        )

    def __str__(self) -> str:
        """Informal string representation (for print or format)."""
        return self.__repr__()

    def __getitem__(self, inds: int | list[int] | slice) -> np.ndarray:
        """Return the frames of the video at the given indices.

        Args:
            inds: Index or list of indices of frames to read.

        Returns:
            Frame or frames as a numpy array of shape `(height, width, channels)` if a
            scalar index is provided, or `(frames, height, width, channels)` if a list
            of indices is provided.

        See also: VideoBackend.get_frame, VideoBackend.get_frames
        """
        if not self.is_open:
            if self.open_backend:
                self.open()
            else:
                raise ValueError(
                    "Video backend is not open. Call video.open() or set "
                    "video.open_backend to True to do automatically on frame read."
                )
        return self.backend[inds]

    def exists(self, check_all: bool = False, dataset: str | None = None) -> bool:
        """Check if the video file exists and is accessible.

        Args:
            check_all: If `True`, check that all filenames in a list exist. If `False`
                (the default), check that the first filename exists.
            dataset: Name of dataset in HDF5 file. If specified, this will function will
                return `False` if the dataset does not exist.

        Returns:
            `True` if the file exists and is accessible, `False` otherwise.
        """
        if isinstance(self.filename, list):
            if check_all:
                for f in self.filename:
                    if not is_file_accessible(f):
                        return False
                return True
            else:
                return is_file_accessible(self.filename[0])

        # URL fast path: must run BEFORE `is_file_accessible`, which treats the
        # filename as a local path and would spuriously return False for a URL.
        from sleap_io.io._remote import _is_url

        if _is_url(self.filename):
            return self._url_exists(dataset)

        file_is_accessible = is_file_accessible(self.filename)
        if not file_is_accessible:
            # Check if it's a directory (ImageVideo source)
            if Path(self.filename).is_dir():
                return True
            return False

        if dataset is None or dataset == "":
            dataset = self.backend_metadata.get("dataset", None)

        if dataset is not None and dataset != "":
            has_dataset = False
            if (
                self.backend is not None
                and type(self.backend) is HDF5Video
                and self.backend._open_reader is not None
            ):
                has_dataset = dataset in self.backend._open_reader
            else:
                with h5py.File(self.filename, "r") as f:
                    has_dataset = dataset in f
            return has_dataset

        return True

    def _url_exists(self, dataset: str | None) -> bool:
        """Check whether a remote URL `filename` exists, with a TTL cache.

        Args:
            dataset: Name of dataset in the (remote) HDF5 file. If specified (or
                derivable from `backend_metadata`), existence additionally requires
                that the dataset be present in the file.

        Returns:
            `True` if the URL is reachable (and, if a dataset was requested, the
            dataset exists), `False` otherwise.

        Notes:
            Results are cached per instance keyed by `(filename, dataset)` for a
            TTL (default 60s, overridable via the `SLEAP_IO_EXISTS_TTL` env var) so
            repeated calls (e.g. from the `is_open` property in a GUI render loop)
            do not issue a network probe each time.
        """
        from sleap_io.io._remote import _head_or_range_probe

        key = (self.filename, dataset)
        try:
            ttl = float(os.environ.get("SLEAP_IO_EXISTS_TTL", "60"))
        except ValueError:
            # A malformed env value must not break the never-raise bool
            # contract of exists()/is_open; fall back to the 60s default.
            ttl = 60.0
        cached = self._exists_cache.get(key)
        if cached is not None and (time.monotonic() - cached[1]) < ttl:
            return cached[0]

        try:
            if not _head_or_range_probe(
                self.filename, headers=self._backend_url_headers()
            ):
                result = False
            else:
                if dataset is None or dataset == "":
                    dataset = self.backend_metadata.get("dataset", None)
                if dataset is None or dataset == "":
                    result = True
                else:
                    result = self._url_dataset_exists(dataset)
        except Exception:
            result = False

        self._exists_cache[key] = (result, time.monotonic())
        return result

    def _url_dataset_exists(self, dataset: str) -> bool:
        """Check whether `dataset` is present in the remote HDF5 file.

        Reuses the backend's already-open HDF5 reader when available; otherwise
        opens the remote file via fsspec for a single membership check.

        Args:
            dataset: Name of dataset in the remote HDF5 file.

        Returns:
            `True` if the dataset is present, `False` otherwise.
        """
        if (
            self.backend is not None
            and type(self.backend) is HDF5Video
            and self.backend._open_reader is not None
        ):
            return dataset in self.backend._open_reader

        from sleap_io.io._remote import open_remote_h5

        url_file = open_remote_h5(self.filename, headers=self._backend_url_headers())
        try:
            with h5py.File(url_file, "r") as f:
                return dataset in f
        finally:
            url_file.close()

    @property
    def is_open(self) -> bool:
        """Check if the video backend is open."""
        return self.exists() and self.backend is not None

    def open(
        self,
        filename: str | None = None,
        dataset: str | None = None,
        grayscale: str | None = None,
        keep_open: bool = True,
        plugin: str | None = None,
    ):
        """Open the video backend for reading.

        Args:
            filename: Filename to open. If not specified, will use the filename set on
                the video object.
            dataset: Name of dataset in HDF5 file.
            grayscale: Whether to force grayscale. If None, autodetect on first frame
                load.
            keep_open: Whether to keep the video reader open between calls to read
                frames. If False, will close the reader after each call. If True (the
                default), it will keep the reader open and cache it for subsequent calls
                which may enhance the performance of reading multiple frames.
            plugin: Video plugin to use for MediaVideo files. One of "opencv",
                "FFMPEG", or "pyav". Also accepts aliases (case-insensitive).
                If not specified, uses the backend metadata, global default,
                or auto-detection in that order.

        Notes:
            This is useful for opening the video backend to read frames and then closing
            it after reading all the necessary frames.

            If the backend was already open, it will be closed before opening a new one.
            Values for the HDF5 dataset and grayscale will be remembered if not
            specified.
        """
        if filename is not None:
            self.replace_filename(filename, open=False)

        # Try to remember values from previous backend if available and not specified.
        if self.backend is not None:
            if dataset is None:
                dataset = getattr(self.backend, "dataset", None)
            if grayscale is None:
                grayscale = getattr(self.backend, "grayscale", None)

        else:
            if dataset is None and "dataset" in self.backend_metadata:
                dataset = self.backend_metadata["dataset"]
            if grayscale is None:
                if "grayscale" in self.backend_metadata:
                    grayscale = self.backend_metadata["grayscale"]
                elif "shape" in self.backend_metadata:
                    grayscale = self.backend_metadata["shape"][-1] == 1

        if not self.exists(dataset=dataset):
            from sleap_io.io._remote import _is_url, _redact_url

            # Redact credential-bearing URLs (e.g. presigned ``?token=`` links)
            # so they never surface in tracebacks/logs. Local paths are shown
            # verbatim.
            name = (
                _redact_url(self.filename)
                if isinstance(self.filename, str) and _is_url(self.filename)
                else self.filename
            )
            msg = f"Video does not exist or cannot be opened for reading: {name}"
            if dataset is not None:
                msg += f" (dataset: {dataset})"
            raise FileNotFoundError(msg)

        # Close previous backend if open.
        self.close()

        # Handle plugin parameter
        backend_kwargs = {}
        if plugin is not None:
            from sleap_io.io.video_reading import normalize_plugin_name

            plugin = normalize_plugin_name(plugin)
            self.backend_metadata["plugin"] = plugin

        if "plugin" in self.backend_metadata:
            backend_kwargs["plugin"] = self.backend_metadata["plugin"]

        # Create new backend. Forward the URL auth context so a reopened remote
        # HDF5Video stays authenticated (the previous backend, and its headers,
        # were dropped by self.close() above).
        self.backend = VideoBackend.from_filename(
            self.filename,
            dataset=dataset,
            grayscale=grayscale,
            keep_open=keep_open,
            url_headers=self._url_headers,
            url_stream_mode=self._url_stream_mode,
            **backend_kwargs,
        )

        # Re-wrap as a crop view if this video records a crop in its metadata.
        # The rebuilt backend above is always a plain backend, so this wraps
        # exactly once (idempotent across close()->open() and deepcopy).
        if "crop" in self.backend_metadata:
            from sleap_io.io.video_reading import CropVideoBackend

            self.backend = CropVideoBackend.wrap(
                inner=self.backend,
                crop=tuple(self.backend_metadata["crop"]),
                fill=self.backend_metadata.get("crop_fill", 0),
            )

    def close(self):
        """Close the video backend."""
        if self.backend is not None:
            # Try to remember values from previous backend if available and not
            # specified.
            try:
                self.backend_metadata["dataset"] = getattr(
                    self.backend, "dataset", None
                )
                self.backend_metadata["grayscale"] = getattr(
                    self.backend, "grayscale", None
                )
                self.backend_metadata["shape"] = getattr(self.backend, "shape", None)
                self.backend_metadata["fps"] = getattr(self.backend, "fps", None)
                # Persist the crop so a Video cropped in-memory (never loaded
                # from disk) survives a close()->open() and deepcopy: open()
                # re-wraps from these keys (the closed-path shape above is
                # already the cropped shape).
                from sleap_io.io.video_reading import CropVideoBackend

                if isinstance(self.backend, CropVideoBackend):
                    self.backend_metadata["crop"] = list(self.backend.crop)
                    self.backend_metadata["crop_fill"] = self.backend.fill
            except Exception:
                pass

            # Deterministically release the backend's open handles (the cached
            # reader and, for a remote HDF5Video, the fsspec URL file-like)
            # rather than relying on garbage collection.
            try:
                self.backend.close()
            except Exception:
                pass

            del self.backend
            self.backend = None

    def replace_filename(
        self, new_filename: str | Path | list[str] | list[Path], open: bool = True
    ):
        """Update the filename of the video, optionally opening the backend.

        Args:
            new_filename: New filename to set for the video.
            open: If `True` (the default), open the backend with the new filename. If
                the new filename does not exist, no error is raised.
        """
        if isinstance(new_filename, Path):
            new_filename = new_filename.as_posix()

        if isinstance(new_filename, list):
            new_filename = [
                p.as_posix() if isinstance(p, Path) else p for p in new_filename
            ]

        # A relink to a different file makes the recorded shape/grayscale/fps in
        # ``backend_metadata`` stale: they describe the OLD file but the new file
        # may have a different resolution/channels/frame rate. They must not be
        # serialized under the new filename (regression from #483, where
        # ``save_slp(prefer_metadata=True)`` prefers these recorded values), so
        # invalidate them on a real relink and let them be recomputed from the new
        # backend. The no-relink path leaves metadata untouched so golden
        # byte-identical saves stay byte-identical.
        filename_changed = new_filename != self.filename

        self.filename = new_filename
        self.backend_metadata["filename"] = new_filename
        # Invalidate any cached URL existence results for the previous filename.
        self._exists_cache.clear()

        if open:
            if self.exists():
                self.open()
            else:
                self.close()

        # Drop stale metadata AFTER (re)opening: ``open()`` internally calls
        # ``close()``, which would otherwise re-stamp the OLD backend's
        # shape/grayscale/fps back into ``backend_metadata``.
        if filename_changed:
            for key in ("shape", "grayscale", "fps"):
                self.backend_metadata.pop(key, None)

    def matches_path(self, other: "Video", strict: bool = False) -> bool:
        """Check if this video has the same path as another video.

        Args:
            other: Another video to compare with.
            strict: If True, require exact path match. If False, consider videos
                with the same filename (basename) as matching.

        Returns:
            True if the videos have matching paths, False otherwise.

        Notes:
            For HDF5 video backends (e.g., embedded videos in .pkg.slp files),
            matching prioritizes the source_filename attribute since multiple
            videos can share the same HDF5 file path but reference different
            source videos. Falls back to dataset name matching if source_filename
            is not available.
        """
        # Handle HDF5 backends specially - prioritize source_filename matching
        self_is_hdf5 = isinstance(self.backend, HDF5Video)
        other_is_hdf5 = isinstance(other.backend, HDF5Video)

        if self_is_hdf5 and other_is_hdf5:
            # Both are HDF5 videos - must match by BOTH source_filename AND dataset
            # to distinguish different videos embedded in the same pkg.slp file
            self_source = self.backend.source_filename
            other_source = other.backend.source_filename
            self_dataset = self.backend.dataset
            other_dataset = other.backend.dataset

            # If both have datasets, they must match
            if self_dataset is not None and other_dataset is not None:
                if self_dataset != other_dataset:
                    return False  # Different datasets = different videos

            # If both have source_filenames, compare them
            if self_source is not None and other_source is not None:
                if strict:
                    # For HDF5 videos, just compare normalized path strings
                    # (avoid slow resolve() on network paths)
                    return Path(self_source).as_posix() == Path(other_source).as_posix()
                else:
                    return Path(self_source).name == Path(other_source).name

            # If only datasets available (no source_filename), they must match
            if self_dataset is not None and other_dataset is not None:
                return self_dataset == other_dataset

            # If neither source_filename nor dataset available, cannot match
            return False

        if isinstance(self.filename, list) and isinstance(other.filename, list):
            # Both are image sequences
            if strict:
                return self.filename == other.filename
            else:
                # Compare basenames
                self_basenames = [Path(f).name for f in self.filename]
                other_basenames = [Path(f).name for f in other.filename]
                return self_basenames == other_basenames
        elif isinstance(self.filename, list) or isinstance(other.filename, list):
            # One is image sequence, other is single file
            return False
        else:
            # Both are single files - use resolve() for symlink handling
            if strict:
                p1, p2 = Path(self.filename), Path(other.filename)
                # Fast string comparison first
                if p1.as_posix() == p2.as_posix():
                    return True
                # Only resolve if both exist locally (avoid slow network timeouts)
                try:
                    if p1.exists() and p2.exists():
                        return p1.resolve() == p2.resolve()
                except OSError:
                    pass
                return False
            else:
                return Path(self.filename).name == Path(other.filename).name

    def matches_content(self, other: "Video") -> bool:
        """Check if this video has the same content as another video.

        Args:
            other: Another video to compare with.

        Returns:
            True if the videos have the same shape and backend type.

        Notes:
            This compares metadata like shape and backend type, not actual frame data.
        """
        # Compare shapes
        self_shape = self.shape
        other_shape = other.shape

        if self_shape != other_shape:
            return False

        # Compare backend types
        if self.backend is None and other.backend is None:
            return True
        elif self.backend is None or other.backend is None:
            return False

        return type(self.backend).__name__ == type(other.backend).__name__

    def matches_shape(self, other: "Video") -> bool:
        """Check if this video has the same shape as another video.

        Args:
            other: Another video to compare with.

        Returns:
            True if the videos have the same height, width, and channels.

        Notes:
            This only compares spatial dimensions, not the number of frames.
        """
        # Try to get shape from backend metadata first if shape is not available
        if self.backend is None and "shape" in self.backend_metadata:
            self_shape = self.backend_metadata["shape"]
        else:
            self_shape = self.shape

        if other.backend is None and "shape" in other.backend_metadata:
            other_shape = other.backend_metadata["shape"]
        else:
            other_shape = other.shape

        # Handle None shapes
        if self_shape is None or other_shape is None:
            return False

        # Compare only height, width, channels (not frames)
        return self_shape[1:] == other_shape[1:]

    def has_overlapping_images(self, other: "Video") -> bool:
        """Check if this video has overlapping images with another video.

        This method is specifically for ImageVideo backends (image sequences).

        Args:
            other: Another video to compare with.

        Returns:
            True if both are ImageVideo instances with overlapping image files.
            False if either video is not an ImageVideo or no overlap exists.

        Notes:
            Only works with ImageVideo backends where filename is a list.
            Compares individual image filenames (basenames only).
        """
        # Both must be image sequences
        if not (isinstance(self.filename, list) and isinstance(other.filename, list)):
            return False

        # Get basenames for comparison
        self_basenames = set(Path(f).name for f in self.filename)
        other_basenames = set(Path(f).name for f in other.filename)

        # Check if there's any overlap
        return len(self_basenames & other_basenames) > 0

    def deduplicate_with(self, other: "Video") -> "Video":
        """Create a new video with duplicate images removed.

        This method is specifically for ImageVideo backends (image sequences).

        Args:
            other: Another video to deduplicate against. Must also be ImageVideo.

        Returns:
            A new Video object with duplicate images removed from this video,
            or None if all images were duplicates.

        Raises:
            ValueError: If either video is not an ImageVideo backend.

        Notes:
            Only works with ImageVideo backends where filename is a list.
            Images are considered duplicates if they have the same basename.
            The returned video contains only images from this video that are
            not present in the other video.
        """
        if not isinstance(self.filename, list):
            raise ValueError("deduplicate_with only works with ImageVideo backends")
        if not isinstance(other.filename, list):
            raise ValueError("Other video must also be ImageVideo backend")

        # Get basenames from other video
        other_basenames = set(Path(f).name for f in other.filename)

        # Keep only non-duplicate images
        deduplicated_paths = [
            f for f in self.filename if Path(f).name not in other_basenames
        ]

        if not deduplicated_paths:
            # All images were duplicates
            return None

        # Create new video with deduplicated images
        return Video.from_filename(deduplicated_paths, grayscale=self.grayscale)

    def merge_with(self, other: "Video") -> "Video":
        """Merge another video's images into this one.

        This method is specifically for ImageVideo backends (image sequences).

        Args:
            other: Another video to merge with. Must also be ImageVideo.

        Returns:
            A new Video object with unique images from both videos.

        Raises:
            ValueError: If either video is not an ImageVideo backend.

        Notes:
            Only works with ImageVideo backends where filename is a list.
            The merged video contains all unique images from both videos,
            with automatic deduplication based on image basename.
        """
        if not isinstance(self.filename, list):
            raise ValueError("merge_with only works with ImageVideo backends")
        if not isinstance(other.filename, list):
            raise ValueError("Other video must also be ImageVideo backend")

        # Get all unique images (by basename) preserving order
        seen_basenames = set()
        merged_paths = []

        for path in self.filename:
            basename = Path(path).name
            if basename not in seen_basenames:
                merged_paths.append(path)
                seen_basenames.add(basename)

        for path in other.filename:
            basename = Path(path).name
            if basename not in seen_basenames:
                merged_paths.append(path)
                seen_basenames.add(basename)

        # Create new video with merged images
        return Video.from_filename(merged_paths, grayscale=self.grayscale)

    def save(
        self,
        save_path: str | Path,
        frame_inds: list[int] | np.ndarray | None = None,
        fps: float | None = None,
        video_kwargs: dict[str, Any] | None = None,
    ) -> "Video":
        """Save video frames to a new video file.

        Args:
            save_path: Path to the new video file. Should end in MP4.
            frame_inds: Frame indices to save. Can be specified as a list or array of
                frame integers. If not specified, saves all video frames.
            fps: Frames per second for the output video. If not specified, uses the
                source video's FPS if available, otherwise defaults to 30.
            video_kwargs: A dictionary of keyword arguments to provide to
                `sio.save_video` for video compression.

        Returns:
            A new `Video` object pointing to the new video file.
        """
        video_kwargs = {} if video_kwargs is None else video_kwargs.copy()
        frame_inds = np.arange(len(self)) if frame_inds is None else frame_inds

        # Use source video FPS if not explicitly specified
        if fps is None:
            fps = self.fps
        if fps is not None and "fps" not in video_kwargs:
            video_kwargs["fps"] = fps

        with VideoWriter(save_path, **video_kwargs) as vw:
            for frame_ind in frame_inds:
                vw(self[frame_ind])

        new_video = Video.from_filename(save_path, grayscale=self.grayscale)
        return new_video

    def apply_crop(
        self,
        path: str | Path,
        *,
        frame_inds: list[int] | np.ndarray | None = None,
        fps: float | None = None,
        video_kwargs: dict[str, Any] | None = None,
    ) -> "Video":
        """Bake this video's virtual crop into a new physical video file.

        Materializes the cropped frames (``self[i]``, already cropped by the
        virtual :class:`~sleap_io.io.video_reading.CropVideoBackend`) to ``path``
        via :class:`~sleap_io.io.video_writing.VideoWriter`. The crop becomes
        physical: the returned video has no ``CropVideoBackend`` / ``/video_crops``
        entry. ``baked.shape`` equals this video's cropped shape when the cropped
        width and height are multiples of 16; otherwise the H.264 encoder pads the
        bottom/right edges up to the next multiple of 16 (the macro-block size),
        so ``baked.shape`` may exceed the cropped shape on those edges. The
        top-left content is preserved, so coordinates stay aligned regardless.

        This operation is coordinate-neutral. A virtual crop already presents
        cropped-frame coordinates, so baking the cropped pixels does not change
        any point coordinates (unlike ``sio transform --crop``, which applies a
        new crop and adjusts coordinates).

        Provenance is preserved: the returned video's ``source_video`` is the
        uncropped original — ``self.source_video`` (the parent a virtual crop is
        created against), or, for a manually-built crop with no parent, an
        uncropped view reconstructed from the crop backend's inner. So
        ``baked.source_video.shape`` is the uncropped shape while ``baked.shape``
        is the cropped shape, and ``baked.grayscale`` is carried from this video.

        Args:
            path: Path to the new video file. Should end in MP4.
            frame_inds: Frame indices to bake. Can be specified as a list or array
                of frame integers. If not specified, bakes all video frames.
            fps: Frames per second for the output video. If not specified, uses
                this video's FPS if available, otherwise defaults to 30.
            video_kwargs: A dictionary of keyword arguments to provide to
                ``sio.save_video`` for video compression.

        Returns:
            A new ``Video`` pointing to the baked file, with ``source_video`` set
            to the uncropped original (or this video) and ``grayscale`` carried
            from this video.

        Raises:
            ValueError: If this video has no virtual crop to apply (i.e.,
                :meth:`_crop_tuple` returns ``None``). Use :meth:`save` to
                re-encode an uncropped video.
        """
        if self._crop_tuple() is None:
            raise ValueError(
                "apply_crop requires a cropped video (a virtual crop created via "
                "Video.crop / Video.from_crop), but this video has no crop to "
                "apply. Use Video.save to re-encode an uncropped video."
            )

        video_kwargs = {} if video_kwargs is None else video_kwargs.copy()
        if frame_inds is None:
            # A crop over a SPARSELY embedded video (frame_map keys are not the dense
            # range 0..N-1, e.g. {5, 9}) cannot be baked by default: writing the frames
            # compacts them to 0..k-1, so any labeled frame referencing a source index
            # (5, 9) would dangle. Refuse with a clear error rather than crash or
            # silently misalign. An explicit frame_inds bypasses this for advanced use.
            inner = getattr(self.backend, "inner", None)
            frame_map = getattr(inner, "frame_map", None)
            if frame_map:
                keys = sorted(frame_map.keys())
                if keys != list(range(len(keys))):
                    raise ValueError(
                        "Cannot bake a virtual crop over a video with sparsely "
                        f"embedded frames (frame_map keys {keys}): baking would "
                        "compact frames to a contiguous range and break frame_idx "
                        "references. Pass explicit frame_inds to override, or "
                        "materialize from the original source video."
                    )
            frame_inds = np.arange(len(self))

        # Use this video's FPS if not explicitly specified.
        if fps is None:
            fps = self.fps
        if fps is not None and "fps" not in video_kwargs:
            video_kwargs["fps"] = fps

        with VideoWriter(path, **video_kwargs) as vw:
            for frame_ind in frame_inds:
                vw(self[frame_ind])

        baked = Video.from_filename(path, grayscale=self.grayscale)
        # Provenance: the uncropped original. Walk past any still-virtual crop
        # ancestors (a flattened crop-of-crop's source_video may itself be a crop)
        # to the first uncropped ancestor. For a manually-built crop with no parent,
        # reconstruct an uncropped view from the crop backend's inner, so
        # source_video is never a cropped video.
        source = self.source_video
        while source is not None and source._crop_tuple() is not None:
            source = source.source_video
        if source is None:
            inner = getattr(self.backend, "inner", None)
            source = (
                Video(filename=inner.filename, backend=inner)
                if inner is not None
                else self
            )
        baked.source_video = source
        return baked

    def set_video_plugin(self, plugin: str) -> None:
        """Set the video plugin and reopen the video.

        Args:
            plugin: Video plugin to use. One of "opencv", "FFMPEG", or "pyav".
                Also accepts aliases (case-insensitive).

        Raises:
            ValueError: If the video is not a MediaVideo type.

        Examples:
            >>> video.set_video_plugin("opencv")
            >>> video.set_video_plugin("CV2")  # Same as "opencv"
        """
        from sleap_io.io.video_reading import MediaVideo, normalize_plugin_name

        if not self.filename.endswith(MediaVideo.EXTS):
            raise ValueError(f"Cannot set plugin for non-media video: {self.filename}")

        plugin = normalize_plugin_name(plugin)

        # Close current backend if open
        was_open = self.is_open
        if was_open:
            self.close()

        # Update backend metadata
        self.backend_metadata["plugin"] = plugin

        # Reopen with new plugin if it was open
        if was_open:
            self.open()

EXTS = ('mp4', 'avi', 'mov', 'mj2', 'mkv', 'h5', 'hdf5', 'slp', 'png', 'jpg', 'jpeg', 'tif', 'tiff', 'bmp', 'seq') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__annotations__ = {'filename': 'str | list[str]', 'backend': 'VideoBackend | None', 'backend_metadata': 'dict[str, any]', 'source_video': "'Video | None'", 'open_backend': 'bool', '_exists_cache': 'dict[tuple[str, str | None], tuple[bool, float]]', '_url_headers': 'dict[str, str] | None', '_url_stream_mode': 'str'} class-attribute

dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2)

__attrs_own_setattr__ = False class-attribute

Returns True when the argument is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

__attrs_props__ = ClassProps(is_exception=False, is_slotted=True, has_weakref_slot=True, is_frozen=False, kw_only=<KeywordOnly.NO: 'no'>, collected_fields_by_mro=True, added_init=True, added_repr=False, added_eq=False, added_ordering=False, hashability=<Hashability.LEAVE_ALONE: 'leave_alone'>, added_match_args=True, added_str=False, added_pickling=True, on_setattr_hook=<function pipe.<locals>.wrapped_pipe at 0x7f08471ce840>, field_transformer=None) class-attribute

Effective class properties as derived from parameters to attr.s() or define() decorators.

This is the same data structure that attrs uses internally to decide how to construct the final class.

Warning:

This feature is currently **experimental** and is not covered by our
strict backwards-compatibility guarantees.

Attributes:

Name Type Description
is_exception bool

Whether the class is treated as an exception class.

is_slotted bool

Whether the class is slotted <slotted classes>.

has_weakref_slot bool

Whether the class has a slot for weak references.

is_frozen bool

Whether the class is frozen.

kw_only KeywordOnly

Whether / how the class enforces keyword-only arguments on the __init__ method.

collected_fields_by_mro bool

Whether the class fields were collected by method resolution order. That is, correctly but unlike dataclasses.

added_init bool

Whether the class has an attrs-generated __init__ method.

added_repr bool

Whether the class has an attrs-generated __repr__ method.

added_eq bool

Whether the class has attrs-generated equality methods.

added_ordering bool

Whether the class has attrs-generated ordering methods.

hashability Hashability

How hashable <hashing> the class is.

added_match_args bool

Whether the class supports positional match <match> over its fields.

added_str bool

Whether the class has an attrs-generated __str__ method.

added_pickling bool

Whether the class has attrs-generated __getstate__ and __setstate__ methods for pickle.

on_setattr_hook Callable[[Any, Attribute[Any], Any], Any] | None

The class's __setattr__ hook.

field_transformer Callable[[Attribute[Any]], Attribute[Any]] | None

The class's field transformers <transform-fields>.

.. versionadded:: 25.4.0

__doc__ = '`Video` class used by sleap to represent videos and data associated with them.\n\nThis class is used to store information regarding a video and its components.\nIt is used to store the video\'s `filename`, `shape`, and the video\'s `backend`.\n\nTo create a `Video` object, use the `from_filename` method which will select the\nbackend appropriately.\n\nAttributes:\n filename: The filename(s) of the video. Supported extensions: "mp4", "avi",\n "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif",\n "tiff", "bmp", "seq". If the filename is a list, a list of image filenames\n are expected. If filename is a folder, it will be searched for images.\n backend: An object that implements the basic methods for reading and\n manipulating frames of a specific video type.\n backend_metadata: A dictionary of metadata specific to the backend. This is\n useful for storing metadata that requires an open backend (e.g., shape\n information) without having access to the video file itself.\n source_video: The source video object if this is a proxy video. This is present\n when the video contains an embedded subset of frames from another video.\n open_backend: Whether to open the backend when the video is available. If `True`\n (the default), the backend will be automatically opened if the video exists.\n Set this to `False` when you want to manually open the backend, or when the\n you know the video file does not exist and you want to avoid trying to open\n the file.\n _exists_cache: Per-instance TTL cache for the result of `exists()` when the\n `filename` is a remote URL. Keyed by `(filename, dataset)` and storing\n `(exists_bool, monotonic_timestamp)`. This avoids issuing a network probe\n on every call (e.g. from the `is_open` property, which GUIs poll on each\n render). The TTL defaults to 60 seconds and can be overridden via the\n `SLEAP_IO_EXISTS_TTL` environment variable. The cache is cleared on\n `replace_filename`.\n\nNotes:\n Instances of this class are hashed by identity, not by value. This means that\n two `Video` instances with the same attributes will NOT be considered equal in a\n set or dict.\n\nMedia Video Plugin Support:\n For media files (mp4, avi, etc.), the following plugins are supported:\n - "opencv": Uses OpenCV (cv2) for video reading\n - "FFMPEG": Uses imageio-ffmpeg for video reading\n - "pyav": Uses PyAV for video reading\n\n Plugin aliases (case-insensitive):\n - opencv: "opencv", "cv", "cv2", "ocv"\n - FFMPEG: "FFMPEG", "ffmpeg", "imageio-ffmpeg", "imageio_ffmpeg"\n - pyav: "pyav", "av"\n\n Plugin selection priority:\n 1. Explicitly specified plugin parameter\n 2. Backend metadata plugin value\n 3. Global default (set via sio.set_default_video_plugin)\n 4. Auto-detection based on available packages\n\nSee Also:\n VideoBackend: The backend interface for reading video data.\n sleap_io.set_default_video_plugin: Set global default plugin.\n sleap_io.get_default_video_plugin: Get current default plugin.\n' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__firstlineno__ = 102 class-attribute

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.int(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer iteral.

int('0b100', base=0) 4

__match_args__ = ('filename', 'backend', 'backend_metadata', 'source_video', 'open_backend') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__module__ = 'sleap_io.model.video' class-attribute

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.str() (if defined) or repr(object). encoding defaults to 'utf-8'. errors defaults to 'strict'.

__slots__ = ('filename', 'backend', 'backend_metadata', 'source_video', 'open_backend', '_exists_cache', '_url_headers', '_url_stream_mode', '__weakref__') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__static_attributes__ = ('backend', 'filename') class-attribute

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable's items.

If the argument is a tuple, the return value is the same object.

__weakref__ property

list of weak references to the object

crop_fill property

The out-of-bounds fill value for this video's crop (0 if uncropped).

crop_rect property

Crop rect (x1, y1, x2, y2) in source coords, or None if uncropped.

fps property

Return the frames per second of the video.

For MediaVideo backends, this reads FPS from the video container metadata. For other backends (ImageVideo, HDF5Video, TiffVideo), this returns the explicitly set value or None if not set.

Returns:

Type Description

The FPS if known, or None if unavailable/unknown.

grayscale property

Return whether the video is grayscale.

If the video backend is not set or it cannot determine whether the video is grayscale, this will return None.

is_cropped property

Whether this video is a virtual crop of another video.

is_open property

Check if the video backend is open.

original_video property

The root video in the provenance chain.

For embedded videos, this returns the ultimate source video by traversing the source_video chain. Returns None if this video has no source_video (i.e., it IS an original).

This property is computed by following the source_video chain to find the root. For a single-level embedding (A embeds from B), original_video returns B. For multi-level embedding (A <- B <- C), it returns C.

shape property

Return the shape of the video as (num_frames, height, width, channels).

If the video backend is not set or it cannot determine the shape of the video, this will return None.

__attrs_post_init__()

Post init syntactic sugar.

Source code in sleap_io/model/video.py
def __attrs_post_init__(self):
    """Post init syntactic sugar."""
    if self.open_backend and self.backend is None and self.exists():
        try:
            self.open()
        except Exception:
            # If we can't open the backend, just ignore it for now so we don't
            # prevent the user from building the Video object entirely.
            pass

__deepcopy__(memo)

Deep copy the video object.

Source code in sleap_io/model/video.py
def __deepcopy__(self, memo):
    """Deep copy the video object."""
    if id(self) in memo:
        return memo[id(self)]

    reopen = False
    if self.is_open:
        reopen = True
        self.close()

    new_video = Video(
        filename=self.filename,
        backend=None,
        backend_metadata=self.backend_metadata.copy(),
        source_video=self.source_video,
        open_backend=self.open_backend,
    )

    memo[id(self)] = new_video

    if reopen:
        self.open()

    return new_video

__getitem__(inds)

Return the frames of the video at the given indices.

Parameters:

Name Type Description Default
inds int | list[int] | slice

Index or list of indices of frames to read.

required

Returns:

Type Description
ndarray

Frame or frames as a numpy array of shape (height, width, channels) if a scalar index is provided, or (frames, height, width, channels) if a list of indices is provided.

See also: VideoBackend.get_frame, VideoBackend.get_frames

Source code in sleap_io/model/video.py
def __getitem__(self, inds: int | list[int] | slice) -> np.ndarray:
    """Return the frames of the video at the given indices.

    Args:
        inds: Index or list of indices of frames to read.

    Returns:
        Frame or frames as a numpy array of shape `(height, width, channels)` if a
        scalar index is provided, or `(frames, height, width, channels)` if a list
        of indices is provided.

    See also: VideoBackend.get_frame, VideoBackend.get_frames
    """
    if not self.is_open:
        if self.open_backend:
            self.open()
        else:
            raise ValueError(
                "Video backend is not open. Call video.open() or set "
                "video.open_backend to True to do automatically on frame read."
            )
    return self.backend[inds]

__init__(filename, backend=None, backend_metadata=NOTHING, source_video=None, open_backend=True)

Method generated by attrs for class Video.

Source code in sleap_io/model/video.py
"""Data model for videos.

The `Video` class is a SLEAP data structure that stores information regarding
a video and its components used in SLEAP.
"""

from __future__ import annotations

import os
import time
from pathlib import Path
from typing import Any

__len__()

Return the length of the video as the number of frames.

Source code in sleap_io/model/video.py
def __len__(self) -> int:
    """Return the length of the video as the number of frames."""
    shape = self.shape
    return 0 if shape is None else shape[0]

__repr__()

Informal string representation (for print or format).

Source code in sleap_io/model/video.py
def __repr__(self) -> str:
    """Informal string representation (for print or format)."""
    dataset = (
        f"dataset={self.backend.dataset}, "
        if getattr(self.backend, "dataset", "")
        else ""
    )
    return (
        "Video("
        f'filename="{self.filename}", '
        f"shape={self.shape}, "
        f"{dataset}"
        f"backend={type(self.backend).__name__}"
        ")"
    )

__str__()

Informal string representation (for print or format).

Source code in sleap_io/model/video.py
def __str__(self) -> str:
    """Informal string representation (for print or format)."""
    return self.__repr__()

apply_crop(path, *, frame_inds=None, fps=None, video_kwargs=None)

Bake this video's virtual crop into a new physical video file.

Materializes the cropped frames (self[i], already cropped by the virtual :class:~sleap_io.io.video_reading.CropVideoBackend) to path via :class:~sleap_io.io.video_writing.VideoWriter. The crop becomes physical: the returned video has no CropVideoBackend / /video_crops entry. baked.shape equals this video's cropped shape when the cropped width and height are multiples of 16; otherwise the H.264 encoder pads the bottom/right edges up to the next multiple of 16 (the macro-block size), so baked.shape may exceed the cropped shape on those edges. The top-left content is preserved, so coordinates stay aligned regardless.

This operation is coordinate-neutral. A virtual crop already presents cropped-frame coordinates, so baking the cropped pixels does not change any point coordinates (unlike sio transform --crop, which applies a new crop and adjusts coordinates).

Provenance is preserved: the returned video's source_video is the uncropped original — self.source_video (the parent a virtual crop is created against), or, for a manually-built crop with no parent, an uncropped view reconstructed from the crop backend's inner. So baked.source_video.shape is the uncropped shape while baked.shape is the cropped shape, and baked.grayscale is carried from this video.

Parameters:

Name Type Description Default
path str | Path

Path to the new video file. Should end in MP4.

required
frame_inds list[int] | ndarray | None

Frame indices to bake. Can be specified as a list or array of frame integers. If not specified, bakes all video frames.

None
fps float | None

Frames per second for the output video. If not specified, uses this video's FPS if available, otherwise defaults to 30.

None
video_kwargs dict[str, Any] | None

A dictionary of keyword arguments to provide to sio.save_video for video compression.

None

Returns:

Type Description
Video

A new Video pointing to the baked file, with source_video set to the uncropped original (or this video) and grayscale carried from this video.

Raises:

Type Description
ValueError

If this video has no virtual crop to apply (i.e., :meth:_crop_tuple returns None). Use :meth:save to re-encode an uncropped video.

Source code in sleap_io/model/video.py
def apply_crop(
    self,
    path: str | Path,
    *,
    frame_inds: list[int] | np.ndarray | None = None,
    fps: float | None = None,
    video_kwargs: dict[str, Any] | None = None,
) -> "Video":
    """Bake this video's virtual crop into a new physical video file.

    Materializes the cropped frames (``self[i]``, already cropped by the
    virtual :class:`~sleap_io.io.video_reading.CropVideoBackend`) to ``path``
    via :class:`~sleap_io.io.video_writing.VideoWriter`. The crop becomes
    physical: the returned video has no ``CropVideoBackend`` / ``/video_crops``
    entry. ``baked.shape`` equals this video's cropped shape when the cropped
    width and height are multiples of 16; otherwise the H.264 encoder pads the
    bottom/right edges up to the next multiple of 16 (the macro-block size),
    so ``baked.shape`` may exceed the cropped shape on those edges. The
    top-left content is preserved, so coordinates stay aligned regardless.

    This operation is coordinate-neutral. A virtual crop already presents
    cropped-frame coordinates, so baking the cropped pixels does not change
    any point coordinates (unlike ``sio transform --crop``, which applies a
    new crop and adjusts coordinates).

    Provenance is preserved: the returned video's ``source_video`` is the
    uncropped original — ``self.source_video`` (the parent a virtual crop is
    created against), or, for a manually-built crop with no parent, an
    uncropped view reconstructed from the crop backend's inner. So
    ``baked.source_video.shape`` is the uncropped shape while ``baked.shape``
    is the cropped shape, and ``baked.grayscale`` is carried from this video.

    Args:
        path: Path to the new video file. Should end in MP4.
        frame_inds: Frame indices to bake. Can be specified as a list or array
            of frame integers. If not specified, bakes all video frames.
        fps: Frames per second for the output video. If not specified, uses
            this video's FPS if available, otherwise defaults to 30.
        video_kwargs: A dictionary of keyword arguments to provide to
            ``sio.save_video`` for video compression.

    Returns:
        A new ``Video`` pointing to the baked file, with ``source_video`` set
        to the uncropped original (or this video) and ``grayscale`` carried
        from this video.

    Raises:
        ValueError: If this video has no virtual crop to apply (i.e.,
            :meth:`_crop_tuple` returns ``None``). Use :meth:`save` to
            re-encode an uncropped video.
    """
    if self._crop_tuple() is None:
        raise ValueError(
            "apply_crop requires a cropped video (a virtual crop created via "
            "Video.crop / Video.from_crop), but this video has no crop to "
            "apply. Use Video.save to re-encode an uncropped video."
        )

    video_kwargs = {} if video_kwargs is None else video_kwargs.copy()
    if frame_inds is None:
        # A crop over a SPARSELY embedded video (frame_map keys are not the dense
        # range 0..N-1, e.g. {5, 9}) cannot be baked by default: writing the frames
        # compacts them to 0..k-1, so any labeled frame referencing a source index
        # (5, 9) would dangle. Refuse with a clear error rather than crash or
        # silently misalign. An explicit frame_inds bypasses this for advanced use.
        inner = getattr(self.backend, "inner", None)
        frame_map = getattr(inner, "frame_map", None)
        if frame_map:
            keys = sorted(frame_map.keys())
            if keys != list(range(len(keys))):
                raise ValueError(
                    "Cannot bake a virtual crop over a video with sparsely "
                    f"embedded frames (frame_map keys {keys}): baking would "
                    "compact frames to a contiguous range and break frame_idx "
                    "references. Pass explicit frame_inds to override, or "
                    "materialize from the original source video."
                )
        frame_inds = np.arange(len(self))

    # Use this video's FPS if not explicitly specified.
    if fps is None:
        fps = self.fps
    if fps is not None and "fps" not in video_kwargs:
        video_kwargs["fps"] = fps

    with VideoWriter(path, **video_kwargs) as vw:
        for frame_ind in frame_inds:
            vw(self[frame_ind])

    baked = Video.from_filename(path, grayscale=self.grayscale)
    # Provenance: the uncropped original. Walk past any still-virtual crop
    # ancestors (a flattened crop-of-crop's source_video may itself be a crop)
    # to the first uncropped ancestor. For a manually-built crop with no parent,
    # reconstruct an uncropped view from the crop backend's inner, so
    # source_video is never a cropped video.
    source = self.source_video
    while source is not None and source._crop_tuple() is not None:
        source = source.source_video
    if source is None:
        inner = getattr(self.backend, "inner", None)
        source = (
            Video(filename=inner.filename, backend=inner)
            if inner is not None
            else self
        )
    baked.source_video = source
    return baked

close()

Close the video backend.

Source code in sleap_io/model/video.py
def close(self):
    """Close the video backend."""
    if self.backend is not None:
        # Try to remember values from previous backend if available and not
        # specified.
        try:
            self.backend_metadata["dataset"] = getattr(
                self.backend, "dataset", None
            )
            self.backend_metadata["grayscale"] = getattr(
                self.backend, "grayscale", None
            )
            self.backend_metadata["shape"] = getattr(self.backend, "shape", None)
            self.backend_metadata["fps"] = getattr(self.backend, "fps", None)
            # Persist the crop so a Video cropped in-memory (never loaded
            # from disk) survives a close()->open() and deepcopy: open()
            # re-wraps from these keys (the closed-path shape above is
            # already the cropped shape).
            from sleap_io.io.video_reading import CropVideoBackend

            if isinstance(self.backend, CropVideoBackend):
                self.backend_metadata["crop"] = list(self.backend.crop)
                self.backend_metadata["crop_fill"] = self.backend.fill
        except Exception:
            pass

        # Deterministically release the backend's open handles (the cached
        # reader and, for a remote HDF5Video, the fsspec URL file-like)
        # rather than relying on garbage collection.
        try:
            self.backend.close()
        except Exception:
            pass

        del self.backend
        self.backend = None

crop(crop=None, *, bbox=None, roi=None, center=None, size=None, margin=0, fill=0, share_decode=True)

Return a virtual, on-read cropped view of this video.

Exactly one region spec must be given: crop (explicit (x1, y1, x2, y2) rect), bbox, roi (its axis-aligned bounds + margin), or (center, size) for a fixed-size centered/ centroid-following window. The returned Video shares no pixels with this one; frames are decoded on read and cropped (byte-identical to :func:sleap_io.transform.frame.crop_frame). Out-of-bounds regions are pad-filled with fill (never clamped), so the output shape is always exactly (y2 - y1, x2 - x1).

The crop composes (FLATTENS when fills agree and the region is in-bounds) with any existing crop on this video via :meth:CropVideoBackend.wrap. source_video is set to this video for provenance. When share_decode (the default), the new crop reuses this video's backend instance as the shared inner so a mosaic of tiles over one file decodes each source frame once; in that case the new tile does NOT own the shared decoder (this video does).

Parameters:

Name Type Description Default
crop tuple[int, int, int, int] | None

Explicit crop region (x1, y1, x2, y2), x2/y2 exclusive.

None
bbox tuple[float, float, float, float] | None

A bounding box (x1, y1, x2, y2); bounds may be float.

None
roi object | None

Any object exposing axis-aligned .bounds as (minx, miny, maxx, maxy) (e.g. a shapely geometry).

None
center tuple[float, float] | None

Window center (cx, cy) (used with size).

None
size tuple[int, int] | None

Fixed output (width, height) (used with center).

None
margin int

Pixels added around the roi bounds on every side.

0
fill int | tuple[int, ...]

Fill value for out-of-bounds regions.

0
share_decode bool

If True (the default), reuse this video's backend as the shared inner so tiles decode each frame once; the new tile does not own the shared decoder.

True

Returns:

Type Description
Video

A new Video exposing the cropped view.

Source code in sleap_io/model/video.py
def crop(
    self,
    crop: tuple[int, int, int, int] | None = None,
    *,
    bbox: tuple[float, float, float, float] | None = None,
    roi: object | None = None,
    center: tuple[float, float] | None = None,
    size: tuple[int, int] | None = None,
    margin: int = 0,
    fill: int | tuple[int, ...] = 0,
    share_decode: bool = True,
) -> "Video":
    """Return a virtual, on-read cropped view of this video.

    Exactly one region spec must be given: ``crop`` (explicit
    ``(x1, y1, x2, y2)`` rect), ``bbox``, ``roi`` (its axis-aligned bounds +
    ``margin``), or (``center``, ``size``) for a fixed-size centered/
    centroid-following window. The returned ``Video`` shares no pixels with
    this one; frames are decoded on read and cropped (byte-identical to
    :func:`sleap_io.transform.frame.crop_frame`). Out-of-bounds regions are
    pad-filled with ``fill`` (never clamped), so the output shape is always
    exactly ``(y2 - y1, x2 - x1)``.

    The crop composes (FLATTENS when fills agree and the region is in-bounds)
    with any existing crop on this video via
    :meth:`CropVideoBackend.wrap`. ``source_video`` is set to this video for
    provenance. When ``share_decode`` (the default), the new crop reuses this
    video's backend instance as the shared inner so a mosaic of tiles over
    one file decodes each source frame once; in that case the new tile does
    NOT own the shared decoder (this video does).

    Args:
        crop: Explicit crop region ``(x1, y1, x2, y2)``, ``x2``/``y2``
            exclusive.
        bbox: A bounding box ``(x1, y1, x2, y2)``; bounds may be float.
        roi: Any object exposing axis-aligned ``.bounds`` as
            ``(minx, miny, maxx, maxy)`` (e.g. a shapely geometry).
        center: Window center ``(cx, cy)`` (used with ``size``).
        size: Fixed output ``(width, height)`` (used with ``center``).
        margin: Pixels added around the ``roi`` bounds on every side.
        fill: Fill value for out-of-bounds regions.
        share_decode: If ``True`` (the default), reuse this video's backend
            as the shared inner so tiles decode each frame once; the new tile
            does not own the shared decoder.

    Returns:
        A new ``Video`` exposing the cropped view.
    """
    from sleap_io.io.video_reading import CropVideoBackend

    rect = _resolve_crop_rect(crop, bbox, roi, center, size, margin)
    if self.backend is None and self.open_backend:
        self.open()
    if self.backend is None:
        raise ValueError(
            "Cannot crop a video with no open backend. Open it first (set "
            "open_backend=True or call .open()) before cropping."
        )
    inner = self.backend
    cropped_backend = CropVideoBackend.wrap(
        inner=inner, crop=rect, fill=fill, owns_inner=not share_decode
    )

    cropped = Video(
        filename=self.filename,
        backend=cropped_backend,
        source_video=self,
        open_backend=self.open_backend,
    )

    x1, y1, x2, y2 = cropped_backend.crop
    src_shape = self.shape
    cropped.backend_metadata = {
        **self.backend_metadata,
        "shape": (src_shape[0], y2 - y1, x2 - x1, src_shape[3])
        if src_shape is not None
        else None,
        # The uncropped source shape, so a closed re-serialize keeps videos_json
        # describing the full frame even without a live source_video (D-120/DI-2).
        "source_shape": list(src_shape) if src_shape is not None else None,
        # COMPOSED source rect from wrap (D-120): keeps open/closed crop keys
        # identical and root-canonical, and survives close()->open().
        "crop": list(cropped_backend.crop),
        "crop_fill": cropped_backend.fill,
    }
    return cropped

deduplicate_with(other)

Create a new video with duplicate images removed.

This method is specifically for ImageVideo backends (image sequences).

Parameters:

Name Type Description Default
other Video

Another video to deduplicate against. Must also be ImageVideo.

required

Returns:

Type Description
Video

A new Video object with duplicate images removed from this video, or None if all images were duplicates.

Raises:

Type Description
ValueError

If either video is not an ImageVideo backend.

Notes

Only works with ImageVideo backends where filename is a list. Images are considered duplicates if they have the same basename. The returned video contains only images from this video that are not present in the other video.

Source code in sleap_io/model/video.py
def deduplicate_with(self, other: "Video") -> "Video":
    """Create a new video with duplicate images removed.

    This method is specifically for ImageVideo backends (image sequences).

    Args:
        other: Another video to deduplicate against. Must also be ImageVideo.

    Returns:
        A new Video object with duplicate images removed from this video,
        or None if all images were duplicates.

    Raises:
        ValueError: If either video is not an ImageVideo backend.

    Notes:
        Only works with ImageVideo backends where filename is a list.
        Images are considered duplicates if they have the same basename.
        The returned video contains only images from this video that are
        not present in the other video.
    """
    if not isinstance(self.filename, list):
        raise ValueError("deduplicate_with only works with ImageVideo backends")
    if not isinstance(other.filename, list):
        raise ValueError("Other video must also be ImageVideo backend")

    # Get basenames from other video
    other_basenames = set(Path(f).name for f in other.filename)

    # Keep only non-duplicate images
    deduplicated_paths = [
        f for f in self.filename if Path(f).name not in other_basenames
    ]

    if not deduplicated_paths:
        # All images were duplicates
        return None

    # Create new video with deduplicated images
    return Video.from_filename(deduplicated_paths, grayscale=self.grayscale)

exists(check_all=False, dataset=None)

Check if the video file exists and is accessible.

Parameters:

Name Type Description Default
check_all bool

If True, check that all filenames in a list exist. If False (the default), check that the first filename exists.

False
dataset str | None

Name of dataset in HDF5 file. If specified, this will function will return False if the dataset does not exist.

None

Returns:

Type Description
bool

True if the file exists and is accessible, False otherwise.

Source code in sleap_io/model/video.py
def exists(self, check_all: bool = False, dataset: str | None = None) -> bool:
    """Check if the video file exists and is accessible.

    Args:
        check_all: If `True`, check that all filenames in a list exist. If `False`
            (the default), check that the first filename exists.
        dataset: Name of dataset in HDF5 file. If specified, this will function will
            return `False` if the dataset does not exist.

    Returns:
        `True` if the file exists and is accessible, `False` otherwise.
    """
    if isinstance(self.filename, list):
        if check_all:
            for f in self.filename:
                if not is_file_accessible(f):
                    return False
            return True
        else:
            return is_file_accessible(self.filename[0])

    # URL fast path: must run BEFORE `is_file_accessible`, which treats the
    # filename as a local path and would spuriously return False for a URL.
    from sleap_io.io._remote import _is_url

    if _is_url(self.filename):
        return self._url_exists(dataset)

    file_is_accessible = is_file_accessible(self.filename)
    if not file_is_accessible:
        # Check if it's a directory (ImageVideo source)
        if Path(self.filename).is_dir():
            return True
        return False

    if dataset is None or dataset == "":
        dataset = self.backend_metadata.get("dataset", None)

    if dataset is not None and dataset != "":
        has_dataset = False
        if (
            self.backend is not None
            and type(self.backend) is HDF5Video
            and self.backend._open_reader is not None
        ):
            has_dataset = dataset in self.backend._open_reader
        else:
            with h5py.File(self.filename, "r") as f:
                has_dataset = dataset in f
        return has_dataset

    return True

frame_to_seconds(frame_idx)

Convert a frame index to timestamp in seconds.

Parameters:

Name Type Description Default
frame_idx int

Zero-indexed frame number.

required

Returns:

Type Description
float | None

Time in seconds, or None if FPS is unknown.

Notes

This assumes constant frame rate. For variable frame rate videos, the returned timestamp may be approximate.

Source code in sleap_io/model/video.py
def frame_to_seconds(self, frame_idx: int) -> float | None:
    """Convert a frame index to timestamp in seconds.

    Args:
        frame_idx: Zero-indexed frame number.

    Returns:
        Time in seconds, or None if FPS is unknown.

    Notes:
        This assumes constant frame rate. For variable frame rate videos,
        the returned timestamp may be approximate.
    """
    if self.fps is None or self.fps <= 0:
        return None
    return frame_idx / self.fps

from_crop(video, crop=None, *, bbox=None, roi=None, center=None, size=None, margin=0, fill=0, share_decode=True, **kwargs) classmethod

Open video (path or Video) and return a virtual crop.

Accepts the same region specs as :meth:crop (crop/bbox/roi/ center+size); extra keyword arguments are forwarded to :meth:from_filename when video is a path (ignored when it is already a Video).

Parameters:

Name Type Description Default
video str | Path | Video

A path/filename to open, or an existing Video to crop.

required
crop tuple[int, int, int, int] | None

Explicit crop region (x1, y1, x2, y2), x2/y2 exclusive.

None
bbox tuple[float, float, float, float] | None

A bounding box (x1, y1, x2, y2); bounds may be float.

None
roi object | None

An object exposing axis-aligned .bounds (e.g. a shapely geometry); margin is applied around it.

None
center tuple[float, float] | None

Window center (cx, cy) (with size).

None
size tuple[int, int] | None

Fixed output (width, height) (with center).

None
margin int

Pixels added around the roi bounds on every side.

0
fill int | tuple[int, ...]

Fill value for out-of-bounds regions.

0
share_decode bool

If True (default), reuse the source decoder.

True
**kwargs

Forwarded to :meth:from_filename for a path input.

required

Returns:

Type Description
Video

A new Video exposing the cropped view.

Source code in sleap_io/model/video.py
@classmethod
def from_crop(
    cls,
    video: "str | Path | Video",
    crop: tuple[int, int, int, int] | None = None,
    *,
    bbox: tuple[float, float, float, float] | None = None,
    roi: object | None = None,
    center: tuple[float, float] | None = None,
    size: tuple[int, int] | None = None,
    margin: int = 0,
    fill: int | tuple[int, ...] = 0,
    share_decode: bool = True,
    **kwargs,
) -> "Video":
    """Open ``video`` (path or ``Video``) and return a virtual crop.

    Accepts the same region specs as :meth:`crop` (``crop``/``bbox``/``roi``/
    ``center``+``size``); extra keyword arguments are forwarded to
    :meth:`from_filename` when ``video`` is a path (ignored when it is already
    a ``Video``).

    Args:
        video: A path/filename to open, or an existing ``Video`` to crop.
        crop: Explicit crop region ``(x1, y1, x2, y2)``, ``x2``/``y2`` exclusive.
        bbox: A bounding box ``(x1, y1, x2, y2)``; bounds may be float.
        roi: An object exposing axis-aligned ``.bounds`` (e.g. a shapely
            geometry); ``margin`` is applied around it.
        center: Window center ``(cx, cy)`` (with ``size``).
        size: Fixed output ``(width, height)`` (with ``center``).
        margin: Pixels added around the ``roi`` bounds on every side.
        fill: Fill value for out-of-bounds regions.
        share_decode: If ``True`` (default), reuse the source decoder.
        **kwargs: Forwarded to :meth:`from_filename` for a path input.

    Returns:
        A new ``Video`` exposing the cropped view.
    """
    if isinstance(video, (str, Path)):
        video = cls.from_filename(video, **kwargs)
    return video.crop(
        crop,
        bbox=bbox,
        roi=roi,
        center=center,
        size=size,
        margin=margin,
        fill=fill,
        share_decode=share_decode,
    )

from_filename(filename, dataset=None, grayscale=None, keep_open=True, source_video=None, **kwargs) classmethod

Create a Video from a filename.

Parameters:

Name Type Description Default
filename str | list[str]

The filename(s) of the video. Supported extensions: "mp4", "avi", "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif", "tiff", "bmp". If the filename is a list, a list of image filenames are expected. If filename is a folder, it will be searched for images.

required
dataset str | None

Name of dataset in HDF5 file.

None
grayscale bool | None

Whether to force grayscale. If None, autodetect on first frame load.

None
keep_open bool

Whether to keep the video reader open between calls to read frames. If False, will close the reader after each call. If True (the default), it will keep the reader open and cache it for subsequent calls which may enhance the performance of reading multiple frames.

True
source_video Video | None

The source video object if this is a proxy video. This is present when the video contains an embedded subset of frames from another video.

None
**kwargs

Additional backend-specific arguments passed to VideoBackend.from_filename. See VideoBackend.from_filename for supported arguments.

required

Returns:

Type Description
VideoBackend

Video instance with the appropriate backend instantiated.

Source code in sleap_io/model/video.py
@classmethod
def from_filename(
    cls,
    filename: str | list[str],
    dataset: str | None = None,
    grayscale: bool | None = None,
    keep_open: bool = True,
    source_video: "Video | None" = None,
    **kwargs,
) -> VideoBackend:
    """Create a Video from a filename.

    Args:
        filename: The filename(s) of the video. Supported extensions: "mp4", "avi",
            "mov", "mj2", "mkv", "h5", "hdf5", "slp", "png", "jpg", "jpeg", "tif",
            "tiff", "bmp". If the filename is a list, a list of image filenames are
            expected. If filename is a folder, it will be searched for images.
        dataset: Name of dataset in HDF5 file.
        grayscale: Whether to force grayscale. If None, autodetect on first frame
            load.
        keep_open: Whether to keep the video reader open between calls to read
            frames. If False, will close the reader after each call. If True (the
            default), it will keep the reader open and cache it for subsequent calls
            which may enhance the performance of reading multiple frames.
        source_video: The source video object if this is a proxy video. This is
            present when the video contains an embedded subset of frames from
            another video.
        **kwargs: Additional backend-specific arguments passed to
            VideoBackend.from_filename. See VideoBackend.from_filename for supported
            arguments.

    Returns:
        Video instance with the appropriate backend instantiated.
    """
    backend = VideoBackend.from_filename(
        filename,
        dataset=dataset,
        grayscale=grayscale,
        keep_open=keep_open,
        **kwargs,
    )
    # If filename is a directory, VideoBackend.from_filename will expand it
    # to a list of paths to images contained within the directory. In this
    # case we want to use the expanded list as filename
    return cls(
        filename=backend.filename,
        backend=backend,
        source_video=source_video,
    )

has_overlapping_images(other)

Check if this video has overlapping images with another video.

This method is specifically for ImageVideo backends (image sequences).

Parameters:

Name Type Description Default
other Video

Another video to compare with.

required

Returns:

Type Description
bool

True if both are ImageVideo instances with overlapping image files. False if either video is not an ImageVideo or no overlap exists.

Notes

Only works with ImageVideo backends where filename is a list. Compares individual image filenames (basenames only).

Source code in sleap_io/model/video.py
def has_overlapping_images(self, other: "Video") -> bool:
    """Check if this video has overlapping images with another video.

    This method is specifically for ImageVideo backends (image sequences).

    Args:
        other: Another video to compare with.

    Returns:
        True if both are ImageVideo instances with overlapping image files.
        False if either video is not an ImageVideo or no overlap exists.

    Notes:
        Only works with ImageVideo backends where filename is a list.
        Compares individual image filenames (basenames only).
    """
    # Both must be image sequences
    if not (isinstance(self.filename, list) and isinstance(other.filename, list)):
        return False

    # Get basenames for comparison
    self_basenames = set(Path(f).name for f in self.filename)
    other_basenames = set(Path(f).name for f in other.filename)

    # Check if there's any overlap
    return len(self_basenames & other_basenames) > 0

matches_content(other)

Check if this video has the same content as another video.

Parameters:

Name Type Description Default
other Video

Another video to compare with.

required

Returns:

Type Description
bool

True if the videos have the same shape and backend type.

Notes

This compares metadata like shape and backend type, not actual frame data.

Source code in sleap_io/model/video.py
def matches_content(self, other: "Video") -> bool:
    """Check if this video has the same content as another video.

    Args:
        other: Another video to compare with.

    Returns:
        True if the videos have the same shape and backend type.

    Notes:
        This compares metadata like shape and backend type, not actual frame data.
    """
    # Compare shapes
    self_shape = self.shape
    other_shape = other.shape

    if self_shape != other_shape:
        return False

    # Compare backend types
    if self.backend is None and other.backend is None:
        return True
    elif self.backend is None or other.backend is None:
        return False

    return type(self.backend).__name__ == type(other.backend).__name__

matches_path(other, strict=False)

Check if this video has the same path as another video.

Parameters:

Name Type Description Default
other Video

Another video to compare with.

required
strict bool

If True, require exact path match. If False, consider videos with the same filename (basename) as matching.

False

Returns:

Type Description
bool

True if the videos have matching paths, False otherwise.

Notes

For HDF5 video backends (e.g., embedded videos in .pkg.slp files), matching prioritizes the source_filename attribute since multiple videos can share the same HDF5 file path but reference different source videos. Falls back to dataset name matching if source_filename is not available.

Source code in sleap_io/model/video.py
def matches_path(self, other: "Video", strict: bool = False) -> bool:
    """Check if this video has the same path as another video.

    Args:
        other: Another video to compare with.
        strict: If True, require exact path match. If False, consider videos
            with the same filename (basename) as matching.

    Returns:
        True if the videos have matching paths, False otherwise.

    Notes:
        For HDF5 video backends (e.g., embedded videos in .pkg.slp files),
        matching prioritizes the source_filename attribute since multiple
        videos can share the same HDF5 file path but reference different
        source videos. Falls back to dataset name matching if source_filename
        is not available.
    """
    # Handle HDF5 backends specially - prioritize source_filename matching
    self_is_hdf5 = isinstance(self.backend, HDF5Video)
    other_is_hdf5 = isinstance(other.backend, HDF5Video)

    if self_is_hdf5 and other_is_hdf5:
        # Both are HDF5 videos - must match by BOTH source_filename AND dataset
        # to distinguish different videos embedded in the same pkg.slp file
        self_source = self.backend.source_filename
        other_source = other.backend.source_filename
        self_dataset = self.backend.dataset
        other_dataset = other.backend.dataset

        # If both have datasets, they must match
        if self_dataset is not None and other_dataset is not None:
            if self_dataset != other_dataset:
                return False  # Different datasets = different videos

        # If both have source_filenames, compare them
        if self_source is not None and other_source is not None:
            if strict:
                # For HDF5 videos, just compare normalized path strings
                # (avoid slow resolve() on network paths)
                return Path(self_source).as_posix() == Path(other_source).as_posix()
            else:
                return Path(self_source).name == Path(other_source).name

        # If only datasets available (no source_filename), they must match
        if self_dataset is not None and other_dataset is not None:
            return self_dataset == other_dataset

        # If neither source_filename nor dataset available, cannot match
        return False

    if isinstance(self.filename, list) and isinstance(other.filename, list):
        # Both are image sequences
        if strict:
            return self.filename == other.filename
        else:
            # Compare basenames
            self_basenames = [Path(f).name for f in self.filename]
            other_basenames = [Path(f).name for f in other.filename]
            return self_basenames == other_basenames
    elif isinstance(self.filename, list) or isinstance(other.filename, list):
        # One is image sequence, other is single file
        return False
    else:
        # Both are single files - use resolve() for symlink handling
        if strict:
            p1, p2 = Path(self.filename), Path(other.filename)
            # Fast string comparison first
            if p1.as_posix() == p2.as_posix():
                return True
            # Only resolve if both exist locally (avoid slow network timeouts)
            try:
                if p1.exists() and p2.exists():
                    return p1.resolve() == p2.resolve()
            except OSError:
                pass
            return False
        else:
            return Path(self.filename).name == Path(other.filename).name

matches_shape(other)

Check if this video has the same shape as another video.

Parameters:

Name Type Description Default
other Video

Another video to compare with.

required

Returns:

Type Description
bool

True if the videos have the same height, width, and channels.

Notes

This only compares spatial dimensions, not the number of frames.

Source code in sleap_io/model/video.py
def matches_shape(self, other: "Video") -> bool:
    """Check if this video has the same shape as another video.

    Args:
        other: Another video to compare with.

    Returns:
        True if the videos have the same height, width, and channels.

    Notes:
        This only compares spatial dimensions, not the number of frames.
    """
    # Try to get shape from backend metadata first if shape is not available
    if self.backend is None and "shape" in self.backend_metadata:
        self_shape = self.backend_metadata["shape"]
    else:
        self_shape = self.shape

    if other.backend is None and "shape" in other.backend_metadata:
        other_shape = other.backend_metadata["shape"]
    else:
        other_shape = other.shape

    # Handle None shapes
    if self_shape is None or other_shape is None:
        return False

    # Compare only height, width, channels (not frames)
    return self_shape[1:] == other_shape[1:]

merge_with(other)

Merge another video's images into this one.

This method is specifically for ImageVideo backends (image sequences).

Parameters:

Name Type Description Default
other Video

Another video to merge with. Must also be ImageVideo.

required

Returns:

Type Description
Video

A new Video object with unique images from both videos.

Raises:

Type Description
ValueError

If either video is not an ImageVideo backend.

Notes

Only works with ImageVideo backends where filename is a list. The merged video contains all unique images from both videos, with automatic deduplication based on image basename.

Source code in sleap_io/model/video.py
def merge_with(self, other: "Video") -> "Video":
    """Merge another video's images into this one.

    This method is specifically for ImageVideo backends (image sequences).

    Args:
        other: Another video to merge with. Must also be ImageVideo.

    Returns:
        A new Video object with unique images from both videos.

    Raises:
        ValueError: If either video is not an ImageVideo backend.

    Notes:
        Only works with ImageVideo backends where filename is a list.
        The merged video contains all unique images from both videos,
        with automatic deduplication based on image basename.
    """
    if not isinstance(self.filename, list):
        raise ValueError("merge_with only works with ImageVideo backends")
    if not isinstance(other.filename, list):
        raise ValueError("Other video must also be ImageVideo backend")

    # Get all unique images (by basename) preserving order
    seen_basenames = set()
    merged_paths = []

    for path in self.filename:
        basename = Path(path).name
        if basename not in seen_basenames:
            merged_paths.append(path)
            seen_basenames.add(basename)

    for path in other.filename:
        basename = Path(path).name
        if basename not in seen_basenames:
            merged_paths.append(path)
            seen_basenames.add(basename)

    # Create new video with merged images
    return Video.from_filename(merged_paths, grayscale=self.grayscale)

open(filename=None, dataset=None, grayscale=None, keep_open=True, plugin=None)

Open the video backend for reading.

Parameters:

Name Type Description Default
filename str | None

Filename to open. If not specified, will use the filename set on the video object.

None
dataset str | None

Name of dataset in HDF5 file.

None
grayscale str | None

Whether to force grayscale. If None, autodetect on first frame load.

None
keep_open bool

Whether to keep the video reader open between calls to read frames. If False, will close the reader after each call. If True (the default), it will keep the reader open and cache it for subsequent calls which may enhance the performance of reading multiple frames.

True
plugin str | None

Video plugin to use for MediaVideo files. One of "opencv", "FFMPEG", or "pyav". Also accepts aliases (case-insensitive). If not specified, uses the backend metadata, global default, or auto-detection in that order.

None
Notes

This is useful for opening the video backend to read frames and then closing it after reading all the necessary frames.

If the backend was already open, it will be closed before opening a new one. Values for the HDF5 dataset and grayscale will be remembered if not specified.

Source code in sleap_io/model/video.py
def open(
    self,
    filename: str | None = None,
    dataset: str | None = None,
    grayscale: str | None = None,
    keep_open: bool = True,
    plugin: str | None = None,
):
    """Open the video backend for reading.

    Args:
        filename: Filename to open. If not specified, will use the filename set on
            the video object.
        dataset: Name of dataset in HDF5 file.
        grayscale: Whether to force grayscale. If None, autodetect on first frame
            load.
        keep_open: Whether to keep the video reader open between calls to read
            frames. If False, will close the reader after each call. If True (the
            default), it will keep the reader open and cache it for subsequent calls
            which may enhance the performance of reading multiple frames.
        plugin: Video plugin to use for MediaVideo files. One of "opencv",
            "FFMPEG", or "pyav". Also accepts aliases (case-insensitive).
            If not specified, uses the backend metadata, global default,
            or auto-detection in that order.

    Notes:
        This is useful for opening the video backend to read frames and then closing
        it after reading all the necessary frames.

        If the backend was already open, it will be closed before opening a new one.
        Values for the HDF5 dataset and grayscale will be remembered if not
        specified.
    """
    if filename is not None:
        self.replace_filename(filename, open=False)

    # Try to remember values from previous backend if available and not specified.
    if self.backend is not None:
        if dataset is None:
            dataset = getattr(self.backend, "dataset", None)
        if grayscale is None:
            grayscale = getattr(self.backend, "grayscale", None)

    else:
        if dataset is None and "dataset" in self.backend_metadata:
            dataset = self.backend_metadata["dataset"]
        if grayscale is None:
            if "grayscale" in self.backend_metadata:
                grayscale = self.backend_metadata["grayscale"]
            elif "shape" in self.backend_metadata:
                grayscale = self.backend_metadata["shape"][-1] == 1

    if not self.exists(dataset=dataset):
        from sleap_io.io._remote import _is_url, _redact_url

        # Redact credential-bearing URLs (e.g. presigned ``?token=`` links)
        # so they never surface in tracebacks/logs. Local paths are shown
        # verbatim.
        name = (
            _redact_url(self.filename)
            if isinstance(self.filename, str) and _is_url(self.filename)
            else self.filename
        )
        msg = f"Video does not exist or cannot be opened for reading: {name}"
        if dataset is not None:
            msg += f" (dataset: {dataset})"
        raise FileNotFoundError(msg)

    # Close previous backend if open.
    self.close()

    # Handle plugin parameter
    backend_kwargs = {}
    if plugin is not None:
        from sleap_io.io.video_reading import normalize_plugin_name

        plugin = normalize_plugin_name(plugin)
        self.backend_metadata["plugin"] = plugin

    if "plugin" in self.backend_metadata:
        backend_kwargs["plugin"] = self.backend_metadata["plugin"]

    # Create new backend. Forward the URL auth context so a reopened remote
    # HDF5Video stays authenticated (the previous backend, and its headers,
    # were dropped by self.close() above).
    self.backend = VideoBackend.from_filename(
        self.filename,
        dataset=dataset,
        grayscale=grayscale,
        keep_open=keep_open,
        url_headers=self._url_headers,
        url_stream_mode=self._url_stream_mode,
        **backend_kwargs,
    )

    # Re-wrap as a crop view if this video records a crop in its metadata.
    # The rebuilt backend above is always a plain backend, so this wraps
    # exactly once (idempotent across close()->open() and deepcopy).
    if "crop" in self.backend_metadata:
        from sleap_io.io.video_reading import CropVideoBackend

        self.backend = CropVideoBackend.wrap(
            inner=self.backend,
            crop=tuple(self.backend_metadata["crop"]),
            fill=self.backend_metadata.get("crop_fill", 0),
        )

replace_filename(new_filename, open=True)

Update the filename of the video, optionally opening the backend.

Parameters:

Name Type Description Default
new_filename str | Path | list[str] | list[Path]

New filename to set for the video.

required
open bool

If True (the default), open the backend with the new filename. If the new filename does not exist, no error is raised.

True
Source code in sleap_io/model/video.py
def replace_filename(
    self, new_filename: str | Path | list[str] | list[Path], open: bool = True
):
    """Update the filename of the video, optionally opening the backend.

    Args:
        new_filename: New filename to set for the video.
        open: If `True` (the default), open the backend with the new filename. If
            the new filename does not exist, no error is raised.
    """
    if isinstance(new_filename, Path):
        new_filename = new_filename.as_posix()

    if isinstance(new_filename, list):
        new_filename = [
            p.as_posix() if isinstance(p, Path) else p for p in new_filename
        ]

    # A relink to a different file makes the recorded shape/grayscale/fps in
    # ``backend_metadata`` stale: they describe the OLD file but the new file
    # may have a different resolution/channels/frame rate. They must not be
    # serialized under the new filename (regression from #483, where
    # ``save_slp(prefer_metadata=True)`` prefers these recorded values), so
    # invalidate them on a real relink and let them be recomputed from the new
    # backend. The no-relink path leaves metadata untouched so golden
    # byte-identical saves stay byte-identical.
    filename_changed = new_filename != self.filename

    self.filename = new_filename
    self.backend_metadata["filename"] = new_filename
    # Invalidate any cached URL existence results for the previous filename.
    self._exists_cache.clear()

    if open:
        if self.exists():
            self.open()
        else:
            self.close()

    # Drop stale metadata AFTER (re)opening: ``open()`` internally calls
    # ``close()``, which would otherwise re-stamp the OLD backend's
    # shape/grayscale/fps back into ``backend_metadata``.
    if filename_changed:
        for key in ("shape", "grayscale", "fps"):
            self.backend_metadata.pop(key, None)

save(save_path, frame_inds=None, fps=None, video_kwargs=None)

Save video frames to a new video file.

Parameters:

Name Type Description Default
save_path str | Path

Path to the new video file. Should end in MP4.

required
frame_inds list[int] | ndarray | None

Frame indices to save. Can be specified as a list or array of frame integers. If not specified, saves all video frames.

None
fps float | None

Frames per second for the output video. If not specified, uses the source video's FPS if available, otherwise defaults to 30.

None
video_kwargs dict[str, Any] | None

A dictionary of keyword arguments to provide to sio.save_video for video compression.

None

Returns:

Type Description
Video

A new Video object pointing to the new video file.

Source code in sleap_io/model/video.py
def save(
    self,
    save_path: str | Path,
    frame_inds: list[int] | np.ndarray | None = None,
    fps: float | None = None,
    video_kwargs: dict[str, Any] | None = None,
) -> "Video":
    """Save video frames to a new video file.

    Args:
        save_path: Path to the new video file. Should end in MP4.
        frame_inds: Frame indices to save. Can be specified as a list or array of
            frame integers. If not specified, saves all video frames.
        fps: Frames per second for the output video. If not specified, uses the
            source video's FPS if available, otherwise defaults to 30.
        video_kwargs: A dictionary of keyword arguments to provide to
            `sio.save_video` for video compression.

    Returns:
        A new `Video` object pointing to the new video file.
    """
    video_kwargs = {} if video_kwargs is None else video_kwargs.copy()
    frame_inds = np.arange(len(self)) if frame_inds is None else frame_inds

    # Use source video FPS if not explicitly specified
    if fps is None:
        fps = self.fps
    if fps is not None and "fps" not in video_kwargs:
        video_kwargs["fps"] = fps

    with VideoWriter(save_path, **video_kwargs) as vw:
        for frame_ind in frame_inds:
            vw(self[frame_ind])

    new_video = Video.from_filename(save_path, grayscale=self.grayscale)
    return new_video

seconds_to_frame(seconds)

Convert a timestamp in seconds to frame index.

Parameters:

Name Type Description Default
seconds float

Time in seconds from video start.

required

Returns:

Type Description
int | None

Zero-indexed frame number (rounded down), or None if FPS unknown.

Source code in sleap_io/model/video.py
def seconds_to_frame(self, seconds: float) -> int | None:
    """Convert a timestamp in seconds to frame index.

    Args:
        seconds: Time in seconds from video start.

    Returns:
        Zero-indexed frame number (rounded down), or None if FPS unknown.
    """
    if self.fps is None or self.fps <= 0:
        return None
    return int(seconds * self.fps)

set_video_plugin(plugin)

Set the video plugin and reopen the video.

Parameters:

Name Type Description Default
plugin str

Video plugin to use. One of "opencv", "FFMPEG", or "pyav". Also accepts aliases (case-insensitive).

required

Raises:

Type Description
ValueError

If the video is not a MediaVideo type.

Examples:

>>> video.set_video_plugin("opencv")
>>> video.set_video_plugin("CV2")  # Same as "opencv"
Source code in sleap_io/model/video.py
def set_video_plugin(self, plugin: str) -> None:
    """Set the video plugin and reopen the video.

    Args:
        plugin: Video plugin to use. One of "opencv", "FFMPEG", or "pyav".
            Also accepts aliases (case-insensitive).

    Raises:
        ValueError: If the video is not a MediaVideo type.

    Examples:
        >>> video.set_video_plugin("opencv")
        >>> video.set_video_plugin("CV2")  # Same as "opencv"
    """
    from sleap_io.io.video_reading import MediaVideo, normalize_plugin_name

    if not self.filename.endswith(MediaVideo.EXTS):
        raise ValueError(f"Cannot set plugin for non-media video: {self.filename}")

    plugin = normalize_plugin_name(plugin)

    # Close current backend if open
    was_open = self.is_open
    if was_open:
        self.close()

    # Update backend metadata
    self.backend_metadata["plugin"] = plugin

    # Reopen with new plugin if it was open
    if was_open:
        self.open()

to_crop_coords(points)

Map source-frame (x, y) into this video's cropped frame.

Parameters:

Name Type Description Default
points ndarray

Coordinate array of shape (..., 2). NaN values are preserved.

required

Returns:

Type Description
ndarray

Coordinates translated into the cropped frame. If this video is not cropped, a copy of points is returned unchanged.

Source code in sleap_io/model/video.py
def to_crop_coords(self, points: np.ndarray) -> np.ndarray:
    """Map source-frame ``(x, y)`` into this video's cropped frame.

    Args:
        points: Coordinate array of shape ``(..., 2)``. NaN values are
            preserved.

    Returns:
        Coordinates translated into the cropped frame. If this video is not
        cropped, a copy of ``points`` is returned unchanged.
    """
    crop = self._crop_tuple()
    return points.copy() if crop is None else crop_points(points, crop)

to_source_coords(points)

Map cropped-frame (x, y) back to source-frame coordinates.

Inverse of :meth:to_crop_coords.

Parameters:

Name Type Description Default
points ndarray

Coordinate array of shape (..., 2). NaN values are preserved.

required

Returns:

Type Description
ndarray

Coordinates translated back to source coordinates. If this video is not cropped, a copy of points is returned unchanged.

Source code in sleap_io/model/video.py
def to_source_coords(self, points: np.ndarray) -> np.ndarray:
    """Map cropped-frame ``(x, y)`` back to source-frame coordinates.

    Inverse of :meth:`to_crop_coords`.

    Args:
        points: Coordinate array of shape ``(..., 2)``. NaN values are
            preserved.

    Returns:
        Coordinates translated back to source coordinates. If this video is
        not cropped, a copy of ``points`` is returned unchanged.
    """
    crop = self._crop_tuple()
    return points.copy() if crop is None else uncrop_points(points, crop)