import sys
from enum import IntEnum

import cython
import cython.cimports.libav as lib
from cython.cimports.av.dictionary import Dictionary
from cython.cimports.av.error import err_check
from cython.cimports.av.sidedata.sidedata import get_display_rotation
from cython.cimports.av.utils import check_ndarray
from cython.cimports.av.video.format import get_pix_fmt, get_video_format
from cython.cimports.av.video.plane import DLManagedTensor, VideoPlane, kCPU, kCuda
from cython.cimports.cpython.exc import PyErr_Clear
from cython.cimports.cpython.pycapsule import (
    PyCapsule_GetPointer,
    PyCapsule_IsValid,
    PyCapsule_SetName,
)
from cython.cimports.cpython.ref import Py_DECREF, Py_INCREF
from cython.cimports.hwcontext_cuda import AVCUDADeviceContext, CUstream
from cython.cimports.libc.stdint import int64_t, uint8_t, uintptr_t


@cython.cfunc
@cython.nogil
@cython.exceptval(check=False)
def _cuda_device_ctx_free(
    device_ctx: cython.pointer[lib.AVHWDeviceContext],
) -> cython.void:
    owner_ref: cython.pointer[lib.AVBufferRef] = cython.cast(
        cython.pointer[lib.AVBufferRef], device_ctx.user_opaque
    )
    lib.av_buffer_unref(cython.address(owner_ref))
    device_ctx.user_opaque = cython.NULL


@cython.final
@cython.cclass
class CudaContext:
    """A reusable FFmpeg CUDA context for frames imported with DLPack.

    :param int device_id: CUDA device ordinal.
    :param bool primary_ctx: Retain the device's primary CUDA context.
    :param bool current_ctx: Wrap the CUDA context current on the calling thread
        when this object is first used. This is mutually exclusive with
        ``primary_ctx``.
    :param int cuda_stream: Optional raw ``CUstream`` pointer for FFmpeg CUDA
        operations, including NVENC input and output. The caller owns the
        stream and must keep it alive as long as this context can be used.
        Nonzero stream pointers are currently supported only for logical CUDA
        device 0. To use another physical GPU, expose it as logical device 0
        via ``CUDA_VISIBLE_DEVICES``.
    """

    def __cinit__(
        self,
        device_id: cython.int = 0,
        primary_ctx: cython.bint = True,
        current_ctx: cython.bint = False,
        cuda_stream: object = None,
    ):
        self.device_id = device_id
        self.primary_ctx = primary_ctx
        self.current_ctx = current_ctx
        self.cuda_stream = 0
        self._device_ref = cython.NULL
        self._frames_cache = {}
        if primary_ctx and current_ctx:
            raise ValueError("primary_ctx and current_ctx are mutually exclusive")
        if cuda_stream is not None:
            if not isinstance(cuda_stream, int):
                raise TypeError("cuda_stream must be an integer or None")
            if cuda_stream < 0:
                raise ValueError("cuda_stream must be non-negative")
            self.cuda_stream = cython.cast(uintptr_t, cuda_stream)
        if self.cuda_stream and self.device_id != 0:
            raise ValueError(
                "cuda_stream currently requires device_id 0; expose the desired "
                "physical GPU as logical device 0 via CUDA_VISIBLE_DEVICES"
            )

    def __dealloc__(self):
        ref: cython.pointer[lib.AVBufferRef]

        for v in self._frames_cache.values():
            ref = cython.cast(
                cython.pointer[lib.AVBufferRef],
                cython.cast(cython.size_t, v),
            )
            lib.av_buffer_unref(cython.address(ref))
        self._frames_cache.clear()

        ref = self._device_ref
        if ref != cython.NULL:
            lib.av_buffer_unref(cython.address(ref))
            self._device_ref = cython.NULL

    @cython.cfunc
    def _get_device_ref(self) -> cython.pointer[lib.AVBufferRef]:
        device_ref: cython.pointer[lib.AVBufferRef] = self._device_ref
        owner_ref: cython.pointer[lib.AVBufferRef]
        if device_ref != cython.NULL:
            return device_ref

        owner_ref = cython.NULL
        device_bytes = f"{self.device_id}".encode()
        c_device: cython.p_char = device_bytes
        options_dict = {"primary_ctx": "1" if self.primary_ctx else "0"}
        if self.current_ctx:
            options_dict["current_ctx"] = "1"
        options: Dictionary = Dictionary(options_dict)
        err_check(
            lib.av_hwdevice_ctx_create(
                cython.address(owner_ref),
                lib.AV_HWDEVICE_TYPE_CUDA,
                c_device,
                options.ptr,
                0,
            )
        )
        if not self.cuda_stream:
            self._device_ref = owner_ref
            return owner_ref

        device_ref = lib.av_hwdevice_ctx_alloc(lib.AV_HWDEVICE_TYPE_CUDA)
        if device_ref == cython.NULL:
            lib.av_buffer_unref(cython.address(owner_ref))
            raise MemoryError("av_hwdevice_ctx_alloc() failed")

        try:
            owner_device_ctx: cython.pointer[lib.AVHWDeviceContext] = cython.cast(
                cython.pointer[lib.AVHWDeviceContext], owner_ref.data
            )
            owner_cuda_ctx: cython.pointer[AVCUDADeviceContext] = cython.cast(
                cython.pointer[AVCUDADeviceContext], owner_device_ctx.hwctx
            )
            device_ctx: cython.pointer[lib.AVHWDeviceContext] = cython.cast(
                cython.pointer[lib.AVHWDeviceContext], device_ref.data
            )
            cuda_ctx: cython.pointer[AVCUDADeviceContext] = cython.cast(
                cython.pointer[AVCUDADeviceContext], device_ctx.hwctx
            )
            cuda_ctx.cuda_ctx = owner_cuda_ctx.cuda_ctx
            cuda_ctx.stream = cython.cast(CUstream, self.cuda_stream)
            device_ctx.free = _cuda_device_ctx_free
            device_ctx.user_opaque = cython.cast(cython.p_void, owner_ref)
            owner_ref = cython.NULL
            err_check(lib.av_hwdevice_ctx_init(device_ref))
        except Exception:
            lib.av_buffer_unref(cython.address(device_ref))
            lib.av_buffer_unref(cython.address(owner_ref))
            raise

        self._device_ref = device_ref
        return device_ref

    @cython.cfunc
    def get_frames_ctx(
        self,
        sw_fmt: lib.AVPixelFormat,
        width: cython.int,
        height: cython.int,
    ) -> cython.pointer[lib.AVBufferRef]:
        key = (int(sw_fmt), int(width), int(height))
        cached = self._frames_cache.get(key)
        cached_ref: cython.pointer[lib.AVBufferRef]
        out_ref: cython.pointer[lib.AVBufferRef]

        if cached is not None:
            cached_ref = cython.cast(
                cython.pointer[lib.AVBufferRef],
                cython.cast(cython.size_t, cached),
            )
            out_ref = lib.av_buffer_ref(cached_ref)
            if out_ref == cython.NULL:
                raise MemoryError("av_buffer_ref() failed")
            return out_ref

        device_ref = self._get_device_ref()

        frames_ref: cython.pointer[lib.AVBufferRef] = lib.av_hwframe_ctx_alloc(
            device_ref
        )
        if frames_ref == cython.NULL:
            raise MemoryError("av_hwframe_ctx_alloc() failed")

        try:
            frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast(
                cython.pointer[lib.AVHWFramesContext], frames_ref.data
            )
            frames_ctx.format = get_pix_fmt(b"cuda")
            frames_ctx.sw_format = sw_fmt
            frames_ctx.width = int(width)
            frames_ctx.height = int(height)
            err_check(lib.av_hwframe_ctx_init(frames_ref))
        except Exception:
            lib.av_buffer_unref(cython.address(frames_ref))
            raise

        out_ref = lib.av_buffer_ref(frames_ref)
        if out_ref == cython.NULL:
            lib.av_buffer_unref(cython.address(frames_ref))
            raise MemoryError("av_buffer_ref() failed")

        self._frames_cache[key] = cython.cast(cython.size_t, frames_ref)
        return out_ref


@cython.cfunc
def _consume_dlpack(obj: object, stream: object) -> cython.pointer[DLManagedTensor]:
    capsule: object
    managed: cython.pointer[DLManagedTensor]

    if hasattr(obj, "__dlpack__"):
        capsule = obj.__dlpack__() if stream is None else obj.__dlpack__(stream=stream)
    else:
        capsule = obj

    if not PyCapsule_IsValid(capsule, b"dltensor"):
        PyErr_Clear()
        raise TypeError(
            "expected a DLPack capsule or an object implementing __dlpack__"
        )

    managed = cython.cast(
        cython.pointer[DLManagedTensor],
        PyCapsule_GetPointer(capsule, b"dltensor"),
    )
    if managed == cython.NULL:
        raise ValueError("PyCapsule_GetPointer returned NULL")

    if PyCapsule_SetName(capsule, b"used_dltensor") != 0:
        raise RuntimeError("PyCapsule_SetName failed")

    return managed


@cython.cfunc
@cython.nogil
@cython.exceptval(check=False)
def _dlpack_avbuffer_free(
    opaque: cython.p_void,
    data: cython.pointer[uint8_t],
) -> cython.void:
    managed: cython.pointer[DLManagedTensor] = cython.cast(
        cython.pointer[DLManagedTensor], opaque
    )
    if managed != cython.NULL:
        managed.deleter(managed)


@cython.cfunc
@cython.nogil
@cython.exceptval(check=False)
def _numpy_avbuffer_free(
    opaque: cython.p_void,
    data: cython.pointer[uint8_t],
) -> cython.void:
    if opaque != cython.NULL:
        with cython.gil:
            Py_DECREF(cython.cast(object, opaque))


_cinit_bypass_sentinel = cython.declare(object, object())

# `pix_fmt`s supported by Frame.to_ndarray() and Frame.from_ndarray()
supported_np_pix_fmts = {
    "abgr",
    "argb",
    "bayer_bggr16be",
    "bayer_bggr16le",
    "bayer_bggr8",
    "bayer_gbrg16be",
    "bayer_gbrg16le",
    "bayer_gbrg8",
    "bayer_grbg16be",
    "bayer_grbg16le",
    "bayer_grbg8",
    "bayer_rggb16be",
    "bayer_rggb16le",
    "bayer_rggb8",
    "bgr24",
    "bgr48be",
    "bgr48le",
    "bgr8",
    "bgra",
    "bgra64be",
    "bgra64le",
    "gbrap",
    "gbrap10be",
    "gbrap10le",
    "gbrap12be",
    "gbrap12le",
    "gbrap14be",
    "gbrap14le",
    "gbrap16be",
    "gbrap16le",
    "gbrapf32be",
    "gbrapf32le",
    "gbrp",
    "gbrp10be",
    "gbrp10le",
    "gbrp12be",
    "gbrp12le",
    "gbrp14be",
    "gbrp14le",
    "gbrp16be",
    "gbrp16le",
    "gbrp9be",
    "gbrp9le",
    "gbrpf32be",
    "gbrpf32le",
    "gray",
    "gray10be",
    "gray10le",
    "gray12be",
    "gray12le",
    "gray14be",
    "gray14le",
    "gray16be",
    "gray16le",
    "gray8",
    "gray9be",
    "gray9le",
    "grayf32be",
    "grayf32le",
    "nv12",
    "pal8",
    "rgb24",
    "rgb48be",
    "rgb48le",
    "rgb8",
    "rgba",
    "rgba64be",
    "rgba64le",
    "rgbaf16be",
    "rgbaf16le",
    "rgbaf32be",
    "rgbaf32le",
    "rgbf32be",
    "rgbf32le",
    "yuv420p",
    "yuv420p10le",
    "yuv422p10le",
    "yuv444p",
    "yuv444p16be",
    "yuv444p16le",
    "yuva444p16be",
    "yuva444p16le",
    "yuvj420p",
    "yuvj444p",
    "yuyv422",
}

# Mapping from format name to (itemsize, dtype) for formats where planes
# are simply concatenated into shape (height, width, channels).
_np_pix_fmt_dtypes = cython.declare(
    dict[str, tuple[cython.uint, str]],
    {
        "abgr": (4, "uint8"),
        "argb": (4, "uint8"),
        "bayer_bggr8": (1, "uint8"),
        "bayer_gbrg8": (1, "uint8"),
        "bayer_grbg8": (1, "uint8"),
        "bayer_rggb8": (1, "uint8"),
        "bayer_bggr16le": (2, "uint16"),
        "bayer_bggr16be": (2, "uint16"),
        "bayer_gbrg16le": (2, "uint16"),
        "bayer_gbrg16be": (2, "uint16"),
        "bayer_grbg16le": (2, "uint16"),
        "bayer_grbg16be": (2, "uint16"),
        "bayer_rggb16le": (2, "uint16"),
        "bayer_rggb16be": (2, "uint16"),
        "bgr24": (3, "uint8"),
        "bgr48be": (6, "uint16"),
        "bgr48le": (6, "uint16"),
        "bgr8": (1, "uint8"),
        "bgra": (4, "uint8"),
        "bgra64be": (8, "uint16"),
        "bgra64le": (8, "uint16"),
        "gbrap": (1, "uint8"),
        "gbrap10be": (2, "uint16"),
        "gbrap10le": (2, "uint16"),
        "gbrap12be": (2, "uint16"),
        "gbrap12le": (2, "uint16"),
        "gbrap14be": (2, "uint16"),
        "gbrap14le": (2, "uint16"),
        "gbrap16be": (2, "uint16"),
        "gbrap16le": (2, "uint16"),
        "gbrapf32be": (4, "float32"),
        "gbrapf32le": (4, "float32"),
        "gbrp": (1, "uint8"),
        "gbrp10be": (2, "uint16"),
        "gbrp10le": (2, "uint16"),
        "gbrp12be": (2, "uint16"),
        "gbrp12le": (2, "uint16"),
        "gbrp14be": (2, "uint16"),
        "gbrp14le": (2, "uint16"),
        "gbrp16be": (2, "uint16"),
        "gbrp16le": (2, "uint16"),
        "gbrp9be": (2, "uint16"),
        "gbrp9le": (2, "uint16"),
        "gbrpf32be": (4, "float32"),
        "gbrpf32le": (4, "float32"),
        "gray": (1, "uint8"),
        "gray10be": (2, "uint16"),
        "gray10le": (2, "uint16"),
        "gray12be": (2, "uint16"),
        "gray12le": (2, "uint16"),
        "gray14be": (2, "uint16"),
        "gray14le": (2, "uint16"),
        "gray16be": (2, "uint16"),
        "gray16le": (2, "uint16"),
        "gray8": (1, "uint8"),
        "gray9be": (2, "uint16"),
        "gray9le": (2, "uint16"),
        "grayf32be": (4, "float32"),
        "grayf32le": (4, "float32"),
        "rgb24": (3, "uint8"),
        "rgb48be": (6, "uint16"),
        "rgb48le": (6, "uint16"),
        "rgb8": (1, "uint8"),
        "rgba": (4, "uint8"),
        "rgba64be": (8, "uint16"),
        "rgba64le": (8, "uint16"),
        "rgbaf16be": (8, "float16"),
        "rgbaf16le": (8, "float16"),
        "rgbaf32be": (16, "float32"),
        "rgbaf32le": (16, "float32"),
        "rgbf32be": (12, "float32"),
        "rgbf32le": (12, "float32"),
        "yuv444p": (1, "uint8"),
        "yuv444p16be": (2, "uint16"),
        "yuv444p16le": (2, "uint16"),
        "yuva444p16be": (2, "uint16"),
        "yuva444p16le": (2, "uint16"),
        "yuvj444p": (1, "uint8"),
        "yuyv422": (2, "uint8"),
    },
)


@cython.cfunc
def alloc_video_frame() -> VideoFrame:
    """Get a mostly uninitialized VideoFrame.

    You MUST call VideoFrame._init(...) or VideoFrame._init_user_attributes()
    before exposing to the user.

    """
    return VideoFrame(_cinit_bypass_sentinel)


class PictureType(IntEnum):
    NONE = lib.AV_PICTURE_TYPE_NONE  # Undefined
    I = lib.AV_PICTURE_TYPE_I  # Intra
    P = lib.AV_PICTURE_TYPE_P  # Predicted
    B = lib.AV_PICTURE_TYPE_B  # Bi-directional predicted
    S = lib.AV_PICTURE_TYPE_S  # S(GMC)-VOP MPEG-4
    SI = lib.AV_PICTURE_TYPE_SI  # Switching intra
    SP = lib.AV_PICTURE_TYPE_SP  # Switching predicted
    BI = lib.AV_PICTURE_TYPE_BI  # BI type


_is_big_endian = cython.declare(cython.bint, sys.byteorder == "big")


@cython.cfunc
@cython.inline
def byteswap_array(array, big_endian: cython.bint):
    if _is_big_endian != big_endian:
        return array.byteswap()
    return array


@cython.cfunc
def copy_bytes_to_plane(
    img_bytes,
    plane: VideoPlane,
    bytes_per_pixel: cython.uint,
    flip_horizontal: cython.bint,
    flip_vertical: cython.bint,
):
    i_buf: cython.const[uint8_t][:] = img_bytes
    i_pos: cython.size_t = 0
    i_stride: cython.size_t = plane.width * bytes_per_pixel

    o_buf: uint8_t[:] = plane
    o_pos: cython.size_t = 0
    o_stride: cython.size_t = abs(plane.line_size)

    start_row, end_row, step = cython.declare(cython.int)
    if flip_vertical:
        start_row = plane.height - 1
        end_row = -1
        step = -1
    else:
        start_row = 0
        end_row = plane.height
        step = 1

    for row in range(start_row, end_row, step):
        i_pos = row * i_stride
        if flip_horizontal:
            i: cython.Py_ssize_t
            for i in range(0, i_stride, bytes_per_pixel):
                j: cython.Py_ssize_t
                for j in range(bytes_per_pixel):
                    o_buf[o_pos + i + j] = i_buf[
                        i_pos + i_stride - i - bytes_per_pixel + j
                    ]
        else:
            o_buf[o_pos : o_pos + i_stride] = i_buf[i_pos : i_pos + i_stride]
        o_pos += o_stride


@cython.cfunc
def copy_array_to_plane(array, plane: VideoPlane, bytes_per_pixel: cython.uint):
    imgbytes: bytes = array.tobytes()
    copy_bytes_to_plane(imgbytes, plane, bytes_per_pixel, False, False)


@cython.cfunc
@cython.inline
def useful_array(
    plane: VideoPlane, bytes_per_pixel: cython.uint = 1, dtype: str = "uint8"
):
    """
    Return the useful part of the VideoPlane as a strided array.

    We are simply creating a view that discards any padding which was added for
    alignment.
    """
    import numpy as np

    dtype_obj = np.dtype(dtype)
    line_size = plane.frame.ptr.linesize[plane.index]
    total_line_size = abs(line_size)
    itemsize = dtype_obj.itemsize
    channels = bytes_per_pixel // itemsize

    if channels == 1:
        shape = (plane.height, plane.width)
        strides = (total_line_size, itemsize)
    else:
        shape = (plane.height, plane.width, channels)
        strides = (total_line_size, bytes_per_pixel, itemsize)

    if line_size < 0:
        offset = (plane.height - 1) * total_line_size
        strides = (-total_line_size, *strides[1:])
        return np.ndarray(
            shape, dtype=dtype_obj, buffer=plane, offset=offset, strides=strides
        )

    return np.ndarray(shape, dtype=dtype_obj, buffer=plane, strides=strides)


@cython.cfunc
def check_ndarray_shape(array: object, ok: cython.bint):
    if not ok:
        raise ValueError(f"Unexpected numpy array shape `{array.shape}`")


@cython.final
@cython.cclass
class VideoFrame(Frame):
    def __cinit__(self, width=0, height=0, format="yuv420p"):
        if width is _cinit_bypass_sentinel:
            return

        c_format: lib.AVPixelFormat = get_pix_fmt(format)
        self._init(c_format, width, height)

    @cython.cfunc
    def _init(self, format: lib.AVPixelFormat, width: cython.uint, height: cython.uint):
        res: cython.int = 0

        with cython.nogil:
            self.ptr.width = width
            self.ptr.height = height
            self.ptr.format = format

            # We enforce aligned buffers, otherwise `sws_scale_frame` can perform
            # poorly or even cause out-of-bounds reads and writes.
            if width and height:
                res = lib.av_frame_get_buffer(self.ptr, 16)

        if res:
            err_check(res)

        self._init_user_attributes()

    @cython.cfunc
    def _init_user_attributes(self):
        self.format = get_video_format(
            cython.cast(lib.AVPixelFormat, self.ptr.format),
            self.ptr.width,
            self.ptr.height,
        )

    def __dealloc__(self):
        lib.av_frame_unref(self.ptr)

    @property
    def sw_format(self):
        """
        For a hardware frame (e.g. ``format.name == "cuda"``), the underlying
        software pixel format (``nv12``, ``yuv444p``, ``p010le``, ...). ``None``
        for a regular software frame.

        :type: VideoFormat | None
        """
        if not self.ptr.hw_frames_ctx:
            return None
        frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast(
            cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data
        )
        return get_video_format(frames_ctx.sw_format, self.ptr.width, self.ptr.height)

    def __repr__(self):
        return (
            f"<av.{self.__class__.__name__}, pts={self.pts} {self.format.name} "
            f"{self.width}x{self.height} at 0x{id(self):x}>"
        )

    @property
    def planes(self):
        """
        A tuple of :class:`.VideoPlane` objects.
        """
        # We need to detect which planes actually exist, but also constrain ourselves to
        # the maximum plane count (as determined only by VideoFrames so far), in case
        # the library implementation does not set the last plane to NULL.
        fmt = self.format
        if self.ptr.hw_frames_ctx:
            frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast(
                cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data
            )
            fmt = get_video_format(
                frames_ctx.sw_format, self.ptr.width, self.ptr.height
            )

        max_plane_count: cython.int = 0
        for i in range(fmt.ptr.nb_components):
            count = fmt.ptr.comp[i].plane + 1
            if max_plane_count < count:
                max_plane_count = count
        if fmt.name == "pal8":
            max_plane_count = 2

        plane_count: cython.int = 0
        while plane_count < max_plane_count and self.ptr.extended_data[plane_count]:
            plane_count += 1
        if plane_count == 1:
            return (VideoPlane(self, 0),)
        return tuple([VideoPlane(self, i) for i in range(plane_count)])

    @property
    def width(self):
        """Width of the image, in pixels."""
        return self.ptr.width

    @property
    def height(self):
        """Height of the image, in pixels."""
        return self.ptr.height

    @property
    def rotation(self):
        """The rotation component of the `DISPLAYMATRIX` transformation matrix.

        Returns:
            int: The angle (in degrees) by which the transformation rotates the frame
                counterclockwise. The angle will be in range [-180, 180].
        """
        return get_display_rotation(self)

    @property
    def interlaced_frame(self):
        """Is this frame an interlaced or progressive?"""

        return bool(self.ptr.flags & lib.AV_FRAME_FLAG_INTERLACED)

    @property
    def pict_type(self):
        """Returns an integer that corresponds to the PictureType enum.

        Wraps :ffmpeg:`AVFrame.pict_type`

        :type: int
        """
        return self.ptr.pict_type

    @pict_type.setter
    def pict_type(self, value):
        self.ptr.pict_type = value

    @property
    def colorspace(self):
        """Colorspace of frame.

        Wraps :ffmpeg:`AVFrame.colorspace`.

        """
        return self.ptr.colorspace

    @colorspace.setter
    def colorspace(self, value):
        self.ptr.colorspace = value

    @property
    def color_range(self):
        """Color range of frame.

        Wraps :ffmpeg:`AVFrame.color_range`.

        """
        return self.ptr.color_range

    @color_range.setter
    def color_range(self, value):
        self.ptr.color_range = value

    @property
    def color_trc(self):
        """Transfer characteristic of frame.

        Wraps :ffmpeg:`AVFrame.color_trc`.

        """
        return self.ptr.color_trc

    @color_trc.setter
    def color_trc(self, value):
        self.ptr.color_trc = value

    @property
    def color_primaries(self):
        """Color primaries of frame.

        Wraps :ffmpeg:`AVFrame.color_primaries`.

        """
        return self.ptr.color_primaries

    @color_primaries.setter
    def color_primaries(self, value):
        self.ptr.color_primaries = value

    def reformat(self, *args, **kwargs):
        """reformat(width=None, height=None, format=None, src_colorspace=None, dst_colorspace=None, interpolation=None, threads=None)

        Create a new :class:`VideoFrame` with the given width/height/format/colorspace.

        .. seealso:: :meth:`.VideoReformatter.reformat` for arguments.

        """
        if not self.reformatter:
            self.reformatter = VideoReformatter()
        return self.reformatter.reformat(self, *args, **kwargs)

    def to_rgb(self, **kwargs):
        """Get an RGB version of this frame.

        Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`.

        >>> frame = VideoFrame(1920, 1080)
        >>> frame.format.name
        'yuv420p'
        >>> frame.to_rgb().format.name
        'rgb24'

        """
        return self.reformat(format="rgb24", **kwargs)

    def save(self, filepath: object, **options):
        """Save a VideoFrame as a JPG or PNG.

        :param filepath: str | Path
        :param \\**options: Encoder options, e.g. ``pred="none"`` or
            ``compression_level=1`` for PNG, ``qscale=2`` for JPG. Values are
            coerced to ``str``. The PNG defaults favor file size over speed;
            ``pred="none"`` is roughly 3x faster and 2x larger.
        """
        is_jpg: cython.bint
        name: str = str(filepath)

        if name.endswith(".png"):
            is_jpg = False
        elif name.endswith(".jpg") or name.endswith(".jpeg"):
            is_jpg = True
        else:
            raise ValueError("filepath must end with png or jpg.")

        encoder: str = "mjpeg" if is_jpg else "png"
        pix_fmt: str = "yuvj420p" if is_jpg else "rgb24"

        from av.container.core import open

        with open(filepath, "w", options={"update": "1"}) as output:
            output_stream = output.add_stream(
                encoder,
                pix_fmt=pix_fmt,
                options={k: str(v) for k, v in options.items()},
            )
            output_stream.width = self.width
            output_stream.height = self.height

            output.mux(output_stream.encode(self.reformat(format=pix_fmt)))
            output.mux(output_stream.encode(None))

    def to_image(self, **kwargs):
        """Get an RGB ``PIL.Image`` of this frame.

        Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`.

        .. note:: PIL or Pillow must be installed.

        """
        from PIL import Image

        plane: VideoPlane = self.reformat(format="rgb24", **kwargs).planes[0]

        i_buf: cython.const[uint8_t][:] = plane
        line_size: cython.int = plane.line_size
        i_stride: cython.size_t = abs(line_size)

        o_pos: cython.size_t = 0
        o_stride: cython.size_t = plane.width * 3
        o_size: cython.size_t = plane.height * o_stride
        o_buf: bytearray = bytearray(o_size)

        # For bottom-up frames (negative line_size) the buffer protocol exposes
        # rows from the lowest address, so the top display row is at the far end.
        i_pos: cython.size_t = (plane.height - 1) * i_stride if line_size < 0 else 0

        while o_pos < o_size:
            o_buf[o_pos : o_pos + o_stride] = i_buf[i_pos : i_pos + o_stride]
            if line_size < 0:
                i_pos -= i_stride
            else:
                i_pos += i_stride
            o_pos += o_stride

        return Image.frombytes(
            "RGB", (plane.width, plane.height), bytes(o_buf), "raw", "RGB", 0, 1
        )

    @cython.cdivision(True)
    def to_ndarray(self, channel_last=False, **kwargs):
        """Get a numpy array of this frame.

        Any ``**kwargs`` are passed to :meth:`.VideoReformatter.reformat`.

        The array returned is generally of dimension (height, width, channels).

        :param bool channel_last: If True, the shape of array will be
            (height, width, channels) rather than (channels, height, width) for
            the "yuv444p" and "yuvj444p" formats.

        .. note:: Numpy must be installed.

        .. note:: For formats which return an array of ``uint16``, ``float16`` or ``float32``,
            the samples will be in the system's native byte order.

        .. note:: For ``pal8``, an ``(image, palette)`` tuple will be returned,
            with the palette being in ARGB (PyAV will swap bytes if needed).

        .. note:: For ``gbrp`` formats, channels are flipped to RGB order.

        """
        if self.ptr.hw_frames_ctx and "format" not in kwargs:
            frames_ctx: cython.pointer[lib.AVHWFramesContext] = cython.cast(
                cython.pointer[lib.AVHWFramesContext], self.ptr.hw_frames_ctx.data
            )
            kwargs = dict(kwargs)
            kwargs["format"] = get_video_format(
                frames_ctx.sw_format, self.ptr.width, self.ptr.height
            ).name

        frame: VideoFrame = self.reformat(**kwargs) if len(kwargs) > 0 else self
        if frame.ptr.hw_frames_ctx:
            raise ValueError("Cannot convert a hardware frame to numpy directly.")

        import numpy as np

        # check size
        format_name = frame.format.name
        planes: tuple[VideoPlane, ...] = frame.planes
        # cases planes are simply concatenated in shape (height, width, channels)
        if format_name in _np_pix_fmt_dtypes:
            if format_name == "yuyv422":
                assert frame.ptr.width % 2 == 0, "width has to be even for yuyv422"
                assert frame.ptr.height % 2 == 0, "height has to be even for yuyv422"
            itemsize: cython.uint
            itemsize, dtype = _np_pix_fmt_dtypes[format_name]
            num_planes: cython.size_t = len(planes)
            if num_planes == 1:  # shortcut, avoid memory copy
                array = useful_array(planes[0], itemsize, dtype)
            else:  # general case
                array = np.empty(
                    (frame.ptr.height, frame.ptr.width, num_planes), dtype=dtype
                )
                if format_name.startswith("gbr"):
                    plane_indices = (2, 0, 1, *range(3, num_planes))
                else:
                    plane_indices = range(num_planes)
                for i, p_idx in enumerate(plane_indices):
                    array[:, :, i] = useful_array(planes[p_idx], itemsize, dtype)
            array = byteswap_array(array, format_name.endswith("be"))
            if not channel_last and format_name in {"yuv444p", "yuvj444p"}:
                array = np.moveaxis(array, 2, 0)
            return array

        # special cases
        if format_name in {"yuv420p", "yuvj420p", "yuv422p", "yuv420p10le"}:
            assert frame.ptr.width % 2 == 0, "width has to be even for this format"
            assert frame.ptr.height % 2 == 0, "height has to be even for this format"
            is_10bit: cython.bint = format_name == "yuv420p10le"
            itemsize = 2 if is_10bit else 1
            dtype = "uint16" if is_10bit else "uint8"
            if frame.ptr.width == 0 or frame.ptr.height == 0:
                return np.empty((0, frame.ptr.width), dtype=dtype)
            return np.hstack(
                [
                    useful_array(planes[0], itemsize, dtype).reshape(-1),
                    useful_array(planes[1], itemsize, dtype).reshape(-1),
                    useful_array(planes[2], itemsize, dtype).reshape(-1),
                ]
            ).reshape(-1, frame.ptr.width)
        if format_name == "yuv422p10le":
            assert frame.ptr.width % 2 == 0, "width has to be even for this format"
            assert frame.ptr.height % 2 == 0, "height has to be even for this format"
            # Read planes as uint16 at their original width
            y = useful_array(planes[0], 2, "uint16")
            u = useful_array(planes[1], 2, "uint16")
            v = useful_array(planes[2], 2, "uint16")

            # Double the width of U and V by repeating each value
            u_full = np.repeat(u, 2, axis=1)
            v_full = np.repeat(v, 2, axis=1)
            if channel_last:
                return np.stack([y, u_full, v_full], axis=2)
            return np.stack([y, u_full, v_full], axis=0)
        if format_name == "pal8":
            image = useful_array(planes[0])
            palette = (
                np.frombuffer(planes[1], "i4")
                .astype(">i4")
                .reshape(-1, 1)
                .view(np.uint8)
            )
            return image, palette
        if format_name == "nv12":
            return np.hstack(
                [
                    useful_array(planes[0]).reshape(-1),
                    useful_array(planes[1], 2).reshape(-1),
                ]
            ).reshape(-1, frame.ptr.width)

        raise ValueError(
            f"Conversion to numpy array with format `{format_name}` is not yet supported"
        )

    def set_image(self, img):
        """
        Update content from a ``PIL.Image``.
        """
        if img.mode != "RGB":
            img = img.convert("RGB")

        copy_array_to_plane(img, self.planes[0], 3)

    @staticmethod
    def from_image(img):
        """
        Construct a frame from a ``PIL.Image``.
        """
        frame: VideoFrame = VideoFrame(img.size[0], img.size[1], "rgb24")
        frame.set_image(img)

        return frame

    @staticmethod
    def from_numpy_buffer(array, format="rgb24", width=0):
        """
        Construct a frame from a numpy buffer.

        :param int width: optional width of actual image, if different from the array width.

        .. note:: For formats which expect an array of ``uint16``, ``float16`` or ``float32``,
            the samples must be in the system's native byte order.

        .. note:: for ``gbrp`` formats, channels are assumed to be given in RGB order.

        .. note:: For formats where width of the array is not the same as the width of the image,
        for example with yuv420p images the UV rows at the bottom have padding bytes in the middle of the
        row as well as at the end. To cope with these, callers need to be able to pass the actual width.
        """
        import numpy as np

        height = array.shape[0]
        if not width:
            width = array.shape[1]

        if format in {"rgb24", "bgr24"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (3, 1):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgb48le", "rgb48be", "bgr48le", "bgr48be"}:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (6, 2):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgbf32le", "rgbf32be"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (12, 4):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgba", "bgra", "argb", "abgr"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (4, 1):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgba64le", "rgba64be", "bgra64le", "bgra64be"}:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (8, 2):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgbaf16le", "rgbaf16be"}:
            check_ndarray(array, "float16", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (8, 2):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"rgbaf32le", "rgbaf32be"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (16, 4):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {
            "gray",
            "gray8",
            "rgb8",
            "bgr8",
            "bayer_bggr8",
            "bayer_gbrg8",
            "bayer_grbg8",
            "bayer_rggb8",
        }:
            check_ndarray(array, "uint8", 2)
            if array.strides[1] != 1:
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {
            "gray9be",
            "gray9le",
            "gray10be",
            "gray10le",
            "gray12be",
            "gray12le",
            "gray14be",
            "gray14le",
            "gray16be",
            "gray16le",
            "bayer_bggr16be",
            "bayer_bggr16le",
            "bayer_gbrg16be",
            "bayer_gbrg16le",
            "bayer_grbg16be",
            "bayer_grbg16le",
            "bayer_rggb16be",
            "bayer_rggb16le",
        }:
            check_ndarray(array, "uint16", 2)
            if array.strides[1] != 2:
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"grayf32le", "grayf32be"}:
            check_ndarray(array, "float32", 2)
            if array.strides[1] != 4:
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (array.strides[0],)
        elif format in {"gbrp"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (3, 1):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 3,
                array.strides[0] // 3,
                array.strides[0] // 3,
            )
        elif format in {
            "gbrp9be",
            "gbrp9le",
            "gbrp10be",
            "gbrp10le",
            "gbrp12be",
            "gbrp12le",
            "gbrp14be",
            "gbrp14le",
            "gbrp16be",
            "gbrp16le",
        }:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (6, 2):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 3,
                array.strides[0] // 3,
                array.strides[0] // 3,
            )
        elif format in {"gbrpf32be", "gbrpf32le"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            if array.strides[1:] != (12, 4):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 3,
                array.strides[0] // 3,
                array.strides[0] // 3,
            )
        elif format in {"gbrap"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (4, 1):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
            )
        elif format in {
            "gbrap10be",
            "gbrap10le",
            "gbrap12be",
            "gbrap12le",
            "gbrap14be",
            "gbrap14le",
            "gbrap16be",
            "gbrap16le",
        }:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (8, 2):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
            )
        elif format in {"gbrapf32be", "gbrapf32le"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            if array.strides[1:] != (16, 4):
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            linesizes = (
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
                array.strides[0] // 4,
            )
        elif format in {"yuv420p", "yuvj420p", "nv12"}:
            check_ndarray(array, "uint8", 2)
            check_ndarray_shape(array, array.shape[0] % 3 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)
            height = height // 6 * 4
            if array.strides[1] != 1:
                raise ValueError("provided array does not have C_CONTIGUOUS rows")
            if format in {"yuv420p", "yuvj420p"}:
                # For YUV420 planar formats, the UV plane stride is always half the Y stride.
                linesizes = (
                    array.strides[0],
                    array.strides[0] // 2,
                    array.strides[0] // 2,
                )
            else:
                # Planes where U and V are interleaved have the same stride as Y.
                linesizes = (array.strides[0], array.strides[0])
        else:
            raise ValueError(
                f"Conversion from numpy array with format `{format}` is not yet supported"
            )

        if format.startswith("gbrap"):  # rgba -> gbra
            array = np.ascontiguousarray(np.moveaxis(array[..., [1, 2, 0, 3]], -1, 0))
        elif format.startswith("gbrp"):  # rgb -> gbr
            array = np.ascontiguousarray(np.moveaxis(array[..., [1, 2, 0]], -1, 0))

        frame = VideoFrame(_cinit_bypass_sentinel)
        frame._image_fill_pointers_numpy(array, width, height, linesizes, format)
        return frame

    def _image_fill_pointers_numpy(self, buffer, width, height, linesizes, format):
        # If you want to use the numpy notation, then you need to include the following lines at the top of the file:
        #      cimport numpy as cnp
        #      cnp.import_array()

        # And add the numpy include directories to the setup.py files
        # hint np.get_include()
        # cdef cnp.ndarray[
        #     dtype=cnp.uint8_t, ndim=1,
        #     negative_indices=False, mode='c'] c_buffer
        # c_buffer = buffer.reshape(-1)
        # c_ptr = &c_buffer[0]
        # c_ptr = <uint8_t*> (<void*>(buffer.ctypes.data))

        # Using buffer.ctypes.data helps avoid any kind of usage of the c-api from
        # numpy, which avoid the need to add numpy as a compile time dependency.

        c_data: cython.Py_ssize_t = buffer.ctypes.data
        c_ptr: cython.pointer[uint8_t] = cython.cast(cython.pointer[uint8_t], c_data)
        c_format: lib.AVPixelFormat = get_pix_fmt(format)
        lib.av_frame_unref(self.ptr)

        # Hold on to a reference for the numpy buffer so that it doesn't get accidentally garbage collected
        self.ptr.format = c_format
        self.ptr.width = width
        self.ptr.height = height
        for i, linesize in enumerate(linesizes):
            self.ptr.linesize[i] = linesize

        required = err_check(
            lib.av_image_fill_pointers(
                self.ptr.data,
                cython.cast(lib.AVPixelFormat, self.ptr.format),
                self.ptr.height,
                c_ptr,
                self.ptr.linesize,
            )
        )

        py_buf = cython.cast(object, buffer)
        Py_INCREF(py_buf)

        self.ptr.buf[0] = lib.av_buffer_create(
            c_ptr,
            required,
            _numpy_avbuffer_free,
            cython.cast(cython.p_void, py_buf),
            0,
        )
        if self.ptr.buf[0] == cython.NULL:
            Py_DECREF(py_buf)
            raise MemoryError("av_buffer_create failed")

        self._init_user_attributes()

    @staticmethod
    @cython.cdivision(True)
    def from_ndarray(array, format="rgb24", channel_last=False):
        """
        Construct a frame from a numpy array.

        :param bool channel_last: If False (default), the shape for the yuv444p and yuvj444p
            is given by (channels, height, width) rather than (height, width, channels).

        .. note:: For formats which expect an array of ``uint16``, ``float16`` or ``float32``,
            the samples must be in the system's native byte order.

        .. note:: for ``pal8``, an ``(image, palette)`` pair must be passed. `palette` must
            have shape (256, 4) and is given in ARGB format (PyAV will swap bytes if needed).

        .. note:: for ``gbrp`` formats, channels are assumed to be given in RGB order.

        """
        import numpy as np

        # case layers are concatenated
        channels, itemsize, dtype = {
            "bayer_bggr16be": (1, 2, "uint16"),
            "bayer_bggr16le": (1, 2, "uint16"),
            "bayer_bggr8": (1, 1, "uint8"),
            "bayer_gbrg16be": (1, 2, "uint16"),
            "bayer_gbrg16le": (1, 2, "uint16"),
            "bayer_gbrg8": (1, 1, "uint8"),
            "bayer_grbg16be": (1, 2, "uint16"),
            "bayer_grbg16le": (1, 2, "uint16"),
            "bayer_grbg8": (1, 1, "uint8"),
            "bayer_rggb16be": (1, 2, "uint16"),
            "bayer_rggb16le": (1, 2, "uint16"),
            "bayer_rggb8": (1, 1, "uint8"),
            "bgr8": (1, 1, "uint8"),
            "gbrap": (4, 1, "uint8"),
            "gbrap10be": (4, 2, "uint16"),
            "gbrap10le": (4, 2, "uint16"),
            "gbrap12be": (4, 2, "uint16"),
            "gbrap12le": (4, 2, "uint16"),
            "gbrap14be": (4, 2, "uint16"),
            "gbrap14le": (4, 2, "uint16"),
            "gbrap16be": (4, 2, "uint16"),
            "gbrap16le": (4, 2, "uint16"),
            "gbrapf32be": (4, 4, "float32"),
            "gbrapf32le": (4, 4, "float32"),
            "gbrp": (3, 1, "uint8"),
            "gbrp10be": (3, 2, "uint16"),
            "gbrp10le": (3, 2, "uint16"),
            "gbrp12be": (3, 2, "uint16"),
            "gbrp12le": (3, 2, "uint16"),
            "gbrp14be": (3, 2, "uint16"),
            "gbrp14le": (3, 2, "uint16"),
            "gbrp16be": (3, 2, "uint16"),
            "gbrp16le": (3, 2, "uint16"),
            "gbrp9be": (3, 2, "uint16"),
            "gbrp9le": (3, 2, "uint16"),
            "gbrpf32be": (3, 4, "float32"),
            "gbrpf32le": (3, 4, "float32"),
            "gray": (1, 1, "uint8"),
            "gray10be": (1, 2, "uint16"),
            "gray10le": (1, 2, "uint16"),
            "gray12be": (1, 2, "uint16"),
            "gray12le": (1, 2, "uint16"),
            "gray14be": (1, 2, "uint16"),
            "gray14le": (1, 2, "uint16"),
            "gray16be": (1, 2, "uint16"),
            "gray16le": (1, 2, "uint16"),
            "gray8": (1, 1, "uint8"),
            "gray9be": (1, 2, "uint16"),
            "gray9le": (1, 2, "uint16"),
            "grayf32be": (1, 4, "float32"),
            "grayf32le": (1, 4, "float32"),
            "rgb8": (1, 1, "uint8"),
            "yuv444p": (3, 1, "uint8"),
            "yuv444p16be": (3, 2, "uint16"),
            "yuv444p16le": (3, 2, "uint16"),
            "yuva444p16be": (4, 2, "uint16"),
            "yuva444p16le": (4, 2, "uint16"),
            "yuvj444p": (3, 1, "uint8"),
        }.get(format, (None, None, None))
        if channels is not None:
            if array.ndim == 2:  # (height, width) -> (height, width, 1)
                array = array[:, :, None]
            check_ndarray(array, dtype, 3)
            if not channel_last and format in {"yuv444p", "yuvj444p"}:
                array = np.moveaxis(array, 0, 2)  # (channels, h, w) -> (h, w, channels)
            check_ndarray_shape(array, array.shape[2] == channels)
            array = byteswap_array(array, format.endswith("be"))
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            if frame.format.name.startswith("gbr"):  # rgb -> gbr
                array = np.concatenate(
                    [  # not inplace to avoid bad surprises
                        array[:, :, 1:3],
                        array[:, :, 0:1],
                        array[:, :, 3:],
                    ],
                    axis=2,
                )
            for i in range(channels):
                copy_array_to_plane(array[:, :, i], frame.planes[i], itemsize)
            return frame

        # other cases
        if format == "pal8":
            array, palette = array
            check_ndarray(array, "uint8", 2)
            check_ndarray(palette, "uint8", 2)
            check_ndarray_shape(palette, palette.shape == (256, 4))

            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(array, frame.planes[0], 1)
            frame.planes[1].update(palette.view(">i4").astype("i4").tobytes())
            return frame
        elif format in {"yuv420p", "yuvj420p"}:
            check_ndarray(array, "uint8", 2)
            check_ndarray_shape(array, array.shape[0] % 3 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)

            frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format)
            if frame.width == 0 or frame.height == 0:
                return frame
            u_start = frame.width * frame.height
            v_start = 5 * u_start // 4
            flat = array.reshape(-1)
            copy_array_to_plane(flat[0:u_start], frame.planes[0], 1)
            copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 1)
            copy_array_to_plane(flat[v_start:], frame.planes[2], 1)
            return frame
        elif format == "yuv420p10le":
            check_ndarray(array, "uint16", 2)
            check_ndarray_shape(array, array.shape[0] % 3 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)

            frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format)
            if frame.width == 0 or frame.height == 0:
                return frame
            u_start = frame.width * frame.height
            v_start = 5 * u_start // 4
            flat = array.reshape(-1)
            copy_array_to_plane(flat[0:u_start], frame.planes[0], 2)
            copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 2)
            copy_array_to_plane(flat[v_start:], frame.planes[2], 2)
            return frame
        elif format == "yuv422p":
            check_ndarray(array, "uint8", 2)
            check_ndarray_shape(array, array.shape[0] % 4 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)

            frame = VideoFrame(array.shape[1], array.shape[0] // 2, format)
            if frame.width == 0 or frame.height == 0:
                return frame
            u_start = frame.width * frame.height
            v_start = u_start + u_start // 2
            flat = array.reshape(-1)
            copy_array_to_plane(flat[0:u_start], frame.planes[0], 1)
            copy_array_to_plane(flat[u_start:v_start], frame.planes[1], 1)
            copy_array_to_plane(flat[v_start:], frame.planes[2], 1)
            return frame
        elif format == "yuv422p10le":
            if not isinstance(array, np.ndarray) or array.dtype != np.uint16:
                raise ValueError("Array must be uint16 type")

            # Convert to channel-first if needed
            if channel_last and array.shape[2] == 3:
                array = np.moveaxis(array, 2, 0)
            elif not (array.shape[0] == 3):
                raise ValueError(
                    "Array must have shape (3, height, width) or (height, width, 3)"
                )

            height, width = array.shape[1:]
            if width % 2 != 0 or height % 2 != 0:
                raise ValueError("Width and height must be even")

            frame = VideoFrame(width, height, format)
            copy_array_to_plane(array[0], frame.planes[0], 2)
            # Subsample U and V by taking every other column
            u = array[1, :, ::2].copy()  # Need copy to ensure C-contiguous
            v = array[2, :, ::2].copy()  # Need copy to ensure C-contiguous
            copy_array_to_plane(u, frame.planes[1], 2)
            copy_array_to_plane(v, frame.planes[2], 2)
            return frame
        elif format == "yuyv422":
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[0] % 2 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)
            check_ndarray_shape(array, array.shape[2] == 2)
        elif format in {"rgb24", "bgr24"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
        elif format in {"argb", "rgba", "abgr", "bgra"}:
            check_ndarray(array, "uint8", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
        elif format in {"rgb48be", "rgb48le", "bgr48be", "bgr48le"}:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(
                byteswap_array(array, format.endswith("be")), frame.planes[0], 6
            )
            return frame
        elif format in {"rgbf32be", "rgbf32le"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 3)
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(
                byteswap_array(array, format.endswith("be")), frame.planes[0], 12
            )
            return frame
        elif format in {"rgba64be", "rgba64le", "bgra64be", "bgra64le"}:
            check_ndarray(array, "uint16", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(
                byteswap_array(array, format.endswith("be")), frame.planes[0], 8
            )
            return frame
        elif format in {"rgbaf16be", "rgbaf16le"}:
            check_ndarray(array, "float16", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(
                byteswap_array(array, format.endswith("be")), frame.planes[0], 8
            )
            return frame
        elif format in {"rgbaf32be", "rgbaf32le"}:
            check_ndarray(array, "float32", 3)
            check_ndarray_shape(array, array.shape[2] == 4)
            frame = VideoFrame(array.shape[1], array.shape[0], format)
            copy_array_to_plane(
                byteswap_array(array, format.endswith("be")), frame.planes[0], 16
            )
            return frame
        elif format == "nv12":
            check_ndarray(array, "uint8", 2)
            check_ndarray_shape(array, array.shape[0] % 3 == 0)
            check_ndarray_shape(array, array.shape[1] % 2 == 0)

            frame = VideoFrame(array.shape[1], (array.shape[0] * 2) // 3, format)
            uv_start = frame.width * frame.height
            flat = array.reshape(-1)
            copy_array_to_plane(flat[:uv_start], frame.planes[0], 1)
            copy_array_to_plane(flat[uv_start:], frame.planes[1], 2)
            return frame
        else:
            raise ValueError(
                f"Conversion from numpy array with format `{format}` is not yet supported"
            )

        frame = VideoFrame(array.shape[1], array.shape[0], format)
        copy_array_to_plane(
            array, frame.planes[0], 1 if array.ndim == 2 else array.shape[2]
        )

        return frame

    @staticmethod
    def from_bytes(
        img_bytes: bytes,
        width: int,
        height: int,
        format="rgba",
        flip_horizontal=False,
        flip_vertical=False,
    ):
        frame = VideoFrame(width, height, format)
        if format == "rgba":
            copy_bytes_to_plane(
                img_bytes, frame.planes[0], 4, flip_horizontal, flip_vertical
            )
        elif format in {
            "bayer_bggr8",
            "bayer_rggb8",
            "bayer_gbrg8",
            "bayer_grbg8",
            "bayer_bggr16le",
            "bayer_rggb16le",
            "bayer_gbrg16le",
            "bayer_grbg16le",
            "bayer_bggr16be",
            "bayer_rggb16be",
            "bayer_gbrg16be",
            "bayer_grbg16be",
        }:
            copy_bytes_to_plane(
                img_bytes,
                frame.planes[0],
                1 if format.endswith("8") else 2,
                flip_horizontal,
                flip_vertical,
            )
        else:
            raise NotImplementedError(f"Format '{format}' is not supported.")
        return frame

    @staticmethod
    def from_dlpack(
        planes,
        format: str = "nv12",
        width: int = 0,
        height: int = 0,
        stream=None,
        device_id: int | None = None,
        primary_ctx: bool | None = None,
        cuda_context=None,
        current_ctx: bool | None = None,
    ):
        if not isinstance(planes, (tuple, list)):
            planes = (planes,)

        sw_fmt: lib.AVPixelFormat = get_pix_fmt(format)
        nv12 = get_pix_fmt(b"nv12")
        p010le = get_pix_fmt(b"p010le")
        p016le = get_pix_fmt(b"p016le")

        if sw_fmt not in (nv12, p010le, p016le):
            return VideoFrame._from_dlpack_planar(
                planes, sw_fmt, format, width, height, stream, device_id
            )

        if len(planes) != 2:
            raise ValueError(
                "from_dlpack currently supports 2-plane formats only (nv12/p010le/p016le)"
            )

        expected_bits = 8 if sw_fmt == nv12 else 16
        itemsize = 1 if expected_bits == 8 else 2

        m0: cython.pointer[DLManagedTensor] = cython.NULL
        m1: cython.pointer[DLManagedTensor] = cython.NULL
        frame: VideoFrame = None

        try:
            m0 = _consume_dlpack(planes[0], stream)
            m1 = _consume_dlpack(planes[1], stream)

            dev_type0 = m0.dl_tensor.device_type
            dev_type1 = m1.dl_tensor.device_type
            if dev_type0 != dev_type1:
                raise ValueError("plane tensors must have the same device_type")
            if dev_type0 not in (kCuda, kCPU):
                raise NotImplementedError(
                    "only CPU and CUDA DLPack tensors are supported"
                )

            dev0 = m0.dl_tensor.device_id
            dev1 = m1.dl_tensor.device_id
            if dev0 != dev1:
                raise ValueError("plane tensors must be on the same CUDA device")
            if dev_type0 == kCuda:
                if device_id is None:
                    device_id = dev0
                elif device_id != dev0:
                    raise ValueError(
                        "device_id does not match the DLPack tensor device_id"
                    )
            else:
                if device_id not in (None, 0):
                    raise ValueError("device_id must be 0 for CPU tensors")
                device_id = 0
            if dev_type0 == kCPU and (dev0 != 0 or dev1 != 0):
                raise ValueError("CPU DLPack tensors must have device_id == 0")

            if (
                m0.dl_tensor.dtype.code != 1
                or m0.dl_tensor.dtype.bits != expected_bits
                or m0.dl_tensor.dtype.lanes != 1
            ):
                raise TypeError("unexpected dtype for plane 0")
            if (
                m1.dl_tensor.dtype.code != 1
                or m1.dl_tensor.dtype.bits != expected_bits
                or m1.dl_tensor.dtype.lanes != 1
            ):
                raise TypeError("unexpected dtype for plane 1")

            if m0.dl_tensor.ndim != 2:
                raise ValueError("plane 0 must be 2D (H, W)")

            y_h = cython.cast(int64_t, m0.dl_tensor.shape[0])
            y_w = cython.cast(int64_t, m0.dl_tensor.shape[1])

            if width == 0 and height == 0:
                width = cython.cast(int, y_w)
                height = cython.cast(int, y_h)
            elif width == 0 or height == 0:
                raise ValueError("either specify both width/height or neither")
            else:
                if y_w != width or y_h != height:
                    raise ValueError("plane 0 shape does not match width/height")

            if width % 2 or height % 2:
                raise ValueError("width/height must be even for nv12/p010le/p016le")

            if m0.dl_tensor.strides != cython.NULL:
                if m0.dl_tensor.strides[1] != 1:
                    raise ValueError("plane 0 must be contiguous in the last dimension")
                y_pitch_elems = cython.cast(int64_t, m0.dl_tensor.strides[0])
            else:
                y_pitch_elems = cython.cast(int64_t, width)

            y_linesize = cython.cast(int, y_pitch_elems * itemsize)
            y_size = cython.cast(int, y_linesize * height)

            uv_ndim = m1.dl_tensor.ndim
            uv_h_expected = height // 2

            if uv_ndim == 2:
                uv_h = cython.cast(int, m1.dl_tensor.shape[0])
                uv_w = cython.cast(int, m1.dl_tensor.shape[1])
                if uv_h != uv_h_expected or uv_w != width:
                    raise ValueError("plane 1 must have shape (H/2, W) for 2D UV")
                if m1.dl_tensor.strides != cython.NULL:
                    if m1.dl_tensor.strides[1] != 1:
                        raise ValueError(
                            "plane 1 must be contiguous in the last dimension"
                        )
                    uv_pitch_elems = cython.cast(int64_t, m1.dl_tensor.strides[0])
                else:
                    uv_pitch_elems = cython.cast(int64_t, uv_w)
            elif uv_ndim == 3:
                uv_h = cython.cast(int, m1.dl_tensor.shape[0])
                uv_w2 = cython.cast(int, m1.dl_tensor.shape[1])
                uv_c = cython.cast(int, m1.dl_tensor.shape[2])
                if uv_h != uv_h_expected or uv_w2 != (width // 2) or uv_c != 2:
                    raise ValueError("plane 1 must have shape (H/2, W/2, 2) for 3D UV")
                if m1.dl_tensor.strides != cython.NULL:
                    if m1.dl_tensor.strides[2] != 1 or m1.dl_tensor.strides[1] != 2:
                        raise ValueError(
                            "unexpected UV plane strides for (H/2, W/2, 2)"
                        )
                    uv_pitch_elems = cython.cast(int64_t, m1.dl_tensor.strides[0])
                else:
                    uv_pitch_elems = cython.cast(int64_t, width)
            else:
                raise ValueError("plane 1 must be 2D or 3D")

            uv_linesize = cython.cast(int, uv_pitch_elems * itemsize)
            uv_size = cython.cast(int, uv_linesize * (height // 2))

            frame = alloc_video_frame()
            frame.ptr.width = width
            frame.ptr.height = height
            if dev_type0 == kCuda:
                ctx: CudaContext
                frames_ref: cython.pointer[lib.AVBufferRef]
                if cuda_context is None:
                    ctx = CudaContext(
                        device_id=device_id,
                        primary_ctx=(
                            not current_ctx if primary_ctx is None else primary_ctx
                        ),
                        current_ctx=bool(current_ctx),
                    )
                else:
                    if not isinstance(cuda_context, CudaContext):
                        raise TypeError("cuda_context must be a CudaContext")
                    if int(cuda_context.device_id) != int(device_id):
                        raise ValueError(
                            "cuda_context.device_id does not match the DLPack tensor device_id"
                        )
                    if primary_ctx is not None and bool(
                        cuda_context.primary_ctx
                    ) != bool(primary_ctx):
                        raise ValueError(
                            "cuda_context.primary_ctx does not match primary_ctx"
                        )
                    if current_ctx is not None and bool(
                        cuda_context.current_ctx
                    ) != bool(current_ctx):
                        raise ValueError(
                            "cuda_context.current_ctx does not match current_ctx"
                        )
                    ctx = cython.cast(CudaContext, cuda_context)

                frames_ref = ctx.get_frames_ctx(sw_fmt, width, height)
                frame.ptr.format = get_pix_fmt(b"cuda")
                frame.ptr.hw_frames_ctx = frames_ref
                frame._device_id = device_id
                frame._cuda_ctx = ctx
            else:
                frame.ptr.format = sw_fmt

            y_ptr = cython.cast(
                cython.pointer[uint8_t], m0.dl_tensor.data
            ) + cython.cast(cython.size_t, m0.dl_tensor.byte_offset)
            uv_ptr = cython.cast(
                cython.pointer[uint8_t], m1.dl_tensor.data
            ) + cython.cast(cython.size_t, m1.dl_tensor.byte_offset)

            frame.ptr.buf[0] = lib.av_buffer_create(
                y_ptr, y_size, _dlpack_avbuffer_free, cython.cast(cython.p_void, m0), 0
            )
            if frame.ptr.buf[0] == cython.NULL:
                raise MemoryError("av_buffer_create failed for plane 0")
            frame.ptr.data[0] = y_ptr
            frame.ptr.linesize[0] = y_linesize
            m0 = cython.NULL

            frame.ptr.buf[1] = lib.av_buffer_create(
                uv_ptr,
                uv_size,
                _dlpack_avbuffer_free,
                cython.cast(cython.p_void, m1),
                0,
            )
            if frame.ptr.buf[1] == cython.NULL:
                raise MemoryError("av_buffer_create failed for plane 1")
            frame.ptr.data[1] = uv_ptr
            frame.ptr.linesize[1] = uv_linesize
            m1 = cython.NULL

            frame._init_user_attributes()
            return frame

        except Exception:
            if frame is not None:
                lib.av_frame_unref(frame.ptr)
            if m0 != cython.NULL:
                m0.deleter(m0)
            if m1 != cython.NULL:
                m1.deleter(m1)
            raise

    @staticmethod
    def _from_dlpack_planar(planes, sw_fmt, format, width, height, stream, device_id):
        # CPU-only import for planar formats whose planes each hold a single
        # component: yuv420p/yuv422p/yuv444p, gray, gbrp, and their 16-bit
        # little-endian variants. nv12/p010le/p016le keep their dedicated
        # 2-plane path in from_dlpack().
        desc: cython.pointer[cython.const[lib.AVPixFmtDescriptor]] = (
            lib.av_pix_fmt_desc_get(sw_fmt)
        )
        if desc == cython.NULL:
            raise NotImplementedError(f"unknown pixel format {format!r}")
        if desc.flags & (
            lib.AV_PIX_FMT_FLAG_BITSTREAM
            | lib.AV_PIX_FMT_FLAG_PAL
            | lib.AV_PIX_FMT_FLAG_BAYER
        ):
            raise NotImplementedError(
                f"from_dlpack does not support bitstream, palette, or Bayer "
                f"formats ({format!r})"
            )

        i: cython.int
        nb_planes: cython.int = 0
        for i in range(desc.nb_components):
            if cython.cast(cython.int, desc.comp[i].plane) + 1 > nb_planes:
                nb_planes = desc.comp[i].plane + 1

        p: cython.int
        count: cython.int
        comp_of_plane = [0] * nb_planes
        for p in range(nb_planes):
            count = 0
            for i in range(desc.nb_components):
                if cython.cast(cython.int, desc.comp[i].plane) == p:
                    comp_of_plane[p] = i
                    count += 1
            if count != 1:
                raise NotImplementedError(
                    "from_dlpack supports nv12/p010le/p016le and planar "
                    f"single-component formats; {format!r} is not supported"
                )

        if len(planes) != nb_planes:
            raise ValueError(
                f"{format!r} requires {nb_planes} plane(s), got {len(planes)}"
            )

        itemsize: cython.int = desc.comp[0].step
        if itemsize != 1 and itemsize != 2:
            raise NotImplementedError(
                "only 8- and 16-bit components are supported for DLPack import"
            )
        if itemsize == 2 and desc.flags & lib.AV_PIX_FMT_FLAG_BE:
            raise NotImplementedError(
                "big-endian formats are not supported for DLPack import"
            )
        expected_bits: cython.int = itemsize * 8

        if device_id not in (None, 0):
            raise ValueError("device_id must be 0 for CPU tensors")

        log2_w: cython.int = desc.log2_chroma_w
        log2_h: cython.int = desc.log2_chroma_h

        frame: VideoFrame = None
        m: cython.pointer[DLManagedTensor] = cython.NULL
        try:
            frame = alloc_video_frame()
            frame.ptr.format = sw_fmt

            for p in range(nb_planes):
                m = _consume_dlpack(planes[p], stream)

                if m.dl_tensor.device_type != kCPU:
                    raise NotImplementedError(
                        "only CPU DLPack tensors are supported for this format"
                    )
                if m.dl_tensor.device_id != 0:
                    raise ValueError("CPU DLPack tensors must have device_id == 0")

                if (
                    m.dl_tensor.dtype.code != 1
                    or m.dl_tensor.dtype.bits != expected_bits
                    or m.dl_tensor.dtype.lanes != 1
                ):
                    raise TypeError(f"unexpected dtype for plane {p}")

                if m.dl_tensor.ndim != 2:
                    raise ValueError(f"plane {p} must be 2D (H, W)")

                ph = cython.cast(int64_t, m.dl_tensor.shape[0])
                pw = cython.cast(int64_t, m.dl_tensor.shape[1])

                if p == 0:
                    if width == 0 and height == 0:
                        width = cython.cast(int, pw)
                        height = cython.cast(int, ph)
                    elif width == 0 or height == 0:
                        raise ValueError("either specify both width/height or neither")
                    elif pw != width or ph != height:
                        raise ValueError("plane 0 shape does not match width/height")
                    if (log2_w and width % 2) or (log2_h and height % 2):
                        raise ValueError(f"width/height must be even for {format!r}")
                    frame.ptr.width = width
                    frame.ptr.height = height

                comp_idx = comp_of_plane[p]
                is_chroma = (comp_idx == 1 or comp_idx == 2) and (log2_w or log2_h)
                exp_w = (-((-width) >> log2_w)) if is_chroma else width
                exp_h = (-((-height) >> log2_h)) if is_chroma else height

                if pw != exp_w or ph != exp_h:
                    raise ValueError(f"plane {p} must have shape ({exp_h}, {exp_w})")

                if m.dl_tensor.strides != cython.NULL:
                    if m.dl_tensor.strides[1] != 1:
                        raise ValueError(
                            f"plane {p} must be contiguous in the last dimension"
                        )
                    pitch_elems = cython.cast(int64_t, m.dl_tensor.strides[0])
                else:
                    pitch_elems = cython.cast(int64_t, exp_w)

                linesize = cython.cast(int, pitch_elems * itemsize)
                size = cython.cast(int, linesize * exp_h)

                ptr = cython.cast(
                    cython.pointer[uint8_t], m.dl_tensor.data
                ) + cython.cast(cython.size_t, m.dl_tensor.byte_offset)

                frame.ptr.buf[p] = lib.av_buffer_create(
                    ptr, size, _dlpack_avbuffer_free, cython.cast(cython.p_void, m), 0
                )
                if frame.ptr.buf[p] == cython.NULL:
                    raise MemoryError(f"av_buffer_create failed for plane {p}")
                frame.ptr.data[p] = ptr
                frame.ptr.linesize[p] = linesize
                m = cython.NULL

            frame._init_user_attributes()
            return frame

        except Exception:
            if frame is not None:
                lib.av_frame_unref(frame.ptr)
            if m != cython.NULL:
                m.deleter(m)
            raise
