diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 62a10684e..64a8311f0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,9 +35,20 @@ Major: - Drop support for Python 3.11. Binary wheels are now built for Python 3.12 and later. - Remove the undocumented ``CodecContext.hwaccel`` attribute. It held the ``HWAccel`` settings object passed in, not the live device context; use ``CodecContext.is_hwaccel`` to check whether hardware acceleration is in use. +- Rational attributes (``time_base``, ``average_rate``, ``base_rate``, ``guessed_rate``, ``framerate``, ``rate``, ``sample_aspect_ratio``, and ``display_aspect_ratio``) now return :class:`av.AVRational` rather than ``fractions.Fraction``, and are never ``None``: an unset value is the falsy ``AVRational(0, 1)``. Test them with ``if not stream.time_base:`` instead of ``is None``. Setters still accept a ``fractions.Fraction``. +- Remove ``Capabilities.hwaccel``, ``Capabilities.hwaccel_vdpau``, and ``Capabilities.neg_linesizes``, none of which FFmpeg defines any more. + +Features: + +- ``av.dump_codecs()`` now lists every codec FFmpeg knows of rather than only those with an encoder or a decoder, so data and attachment codecs appear, matching ``ffmpeg -codecs``. Its legend gains the ``..D...`` and ``..T...`` media types. +- ``ContainerFormat.fixed_framesize`` reports whether a format wants fixed size audio frames. +- Enums gained the members FFmpeg has since added: ``Properties.FIELDS``, ``Properties.ENHANCEMENT``, ``PixFmtLoss.EXCESS_RESOLUTION``, ``PixFmtLoss.EXCESS_DEPTH``, ``Flags2.icc_profiles``, ``format.Flags.experimental``, ``Interpolation.STRICT``, ``Interpolation.UNSTABLE``, ``ColorTrc.V_LOG``, ``ColorPrimaries.V_GAMUT``, and the ``LCEVC``, ``VIEW_ID``, ``THREE_D_REFERENCE_DISPLAYS``, and ``EXIF`` members of ``sidedata.Type``. Fixes: +- ``av.dump_codecs()`` no longer drops the canonical names ``h264``, ``hevc``, ``av1``, ``dirac``, and ``ilbc``, each of which was overwritten by the row of whichever encoder it resolved to. +- ``Frame.side_data`` now satisfies the ``Mapping`` protocol it advertises: iteration yields :class:`~av.sidedata.sidedata.Type` keys, so ``items()``, ``keys()``, and ``values()`` work instead of raising ``KeyError``. Values remain reachable positionally by an integer or, for the first time, a slice. Its type stub was a ``TypedDict`` with a single literal key, and is now ``SideDataContainer``. +- Fix crashes from indexes that were turned into C pointer arithmetic without being range checked. ``MotionVectors[i]`` only checked the upper bound, so a negative index read off the front of the buffer (``mvs[-1]`` now returns the last vector, as with any sequence); ``VideoFormatComponent`` and ``AudioPlane`` accepted any index at all; and ``BitmapSubtitlePlane`` and ``VideoBlockParams`` were missing their lower bounds. - Frames returned by flushing a codec context directly (``CodecContext.decode()`` with no packet) now carry the stream's ``time_base`` instead of ``None``. - ``VideoFrame.reformat()`` (and so ``to_ndarray(format=...)``, ``to_rgb()``, ``to_image()``) now shares one ``SwsContext`` per thread instead of allocating one per frame. FFmpeg 8's swscale retains megabytes of graph state per context, which showed up as large RSS growth when many frames were alive at once. diff --git a/av/audio/plane.py b/av/audio/plane.py index e68b5403e..b0cf6052f 100644 --- a/av/audio/plane.py +++ b/av/audio/plane.py @@ -6,6 +6,12 @@ @cython.cclass class AudioPlane(Plane): def __cinit__(self, frame: AudioFrame, index: cython.int): + nb_planes: cython.int = ( + frame.layout.nb_channels if frame.format.is_planar else 1 + ) + if index < 0 or index >= nb_planes: + raise ValueError(f"plane index {index} out of range for {nb_planes} planes") + # Only the first linesize is ever populated, but it applies to every plane. self.buffer_size = self.frame.ptr.linesize[0] diff --git a/av/codec/codec.py b/av/codec/codec.py index fbe8bbcd9..d2ca14911 100644 --- a/av/codec/codec.py +++ b/av/codec/codec.py @@ -5,7 +5,6 @@ from cython.cimports.av.audio.format import get_audio_format from cython.cimports.av.codec.hwaccel import HWConfig, wrap_hwconfig from cython.cimports.av.rational import from_avrational -from cython.cimports.av.utils import avrational_to_fraction from cython.cimports.av.video.format import VideoFormat, get_pix_fmt, get_video_format from cython.cimports.libc.stdlib import free, malloc diff --git a/av/codec/context.py b/av/codec/context.py index b9d73a7d9..bef98e20d 100644 --- a/av/codec/context.py +++ b/av/codec/context.py @@ -8,7 +8,8 @@ from cython.cimports.av.dictionary import Dictionary from cython.cimports.av.error import err_check from cython.cimports.av.packet import Packet -from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.av.rational import from_avrational +from cython.cimports.av.utils import to_avrational from cython.cimports.libc.errno import EAGAIN from cython.cimports.libc.stdint import uint8_t from cython.cimports.libc.string import memcpy, strcmp @@ -857,7 +858,7 @@ def level(self, value: cython.int): def time_base(self): if self.is_decoder: raise RuntimeError("Cannot access 'time_base' as a decoder") - return avrational_to_fraction(cython.address(self.ptr.time_base)) + return from_avrational(self.ptr.time_base) @time_base.setter def time_base(self, value): diff --git a/av/codec/context.pyi b/av/codec/context.pyi index 690c34ba4..c4062f26a 100644 --- a/av/codec/context.pyi +++ b/av/codec/context.pyi @@ -6,6 +6,7 @@ from typing import ClassVar, Literal, cast, overload from av.audio import _AudioCodecName from av.audio.codeccontext import AudioCodecContext from av.packet import Packet +from av.rational import AVRational from av.subtitles import _SubtitleCodecName from av.subtitles.codeccontext import SubtitleCodecContext from av.video import _VideoCodecName @@ -125,7 +126,10 @@ class CodecContext: @property def profiles(self) -> list[str]: ... extradata: bytes | None - time_base: Fraction + @property + def time_base(self) -> AVRational: ... + @time_base.setter + def time_base(self, value: AVRational | Fraction | int) -> None: ... codec_tag: str global_quality: int bit_rate: int | None diff --git a/av/container/core.py b/av/container/core.py index a17bc8df8..d07b7167b 100755 --- a/av/container/core.py +++ b/av/container/core.py @@ -11,9 +11,9 @@ from cython.cimports.av.container.pyio import pyio_close_custom_gil, pyio_close_gil from cython.cimports.av.error import err_check, stash_exception from cython.cimports.av.format import build_container_format +from cython.cimports.av.rational import from_avrational from cython.cimports.av.utils import ( avdict_to_dict, - avrational_to_fraction, dict_to_avdict, to_avrational, ) @@ -433,7 +433,7 @@ def chapters(self): "id": ch.id, "start": ch.start, "end": ch.end, - "time_base": avrational_to_fraction(cython.address(ch.time_base)), + "time_base": from_avrational(ch.time_base), "metadata": avdict_to_dict( ch.metadata, self.metadata_encoding, self.metadata_errors ), diff --git a/av/container/core.pyi b/av/container/core.pyi index ec4a84d89..9350aa4b4 100644 --- a/av/container/core.pyi +++ b/av/container/core.pyi @@ -7,12 +7,13 @@ from typing import Any, ClassVar, Literal, Self, TypedDict, cast, overload from av.codec.hwaccel import HWAccel from av.format import ContainerFormat +from av.rational import AVRational from .input import InputContainer from .output import OutputContainer from .streams import StreamContainer -Real = int | float | Fraction +Real = int | float | Fraction | AVRational class Flags(Flag): gen_pts = cast(ClassVar[Flags], ...) @@ -72,7 +73,7 @@ class Chapter(TypedDict): id: int start: int end: int - time_base: Fraction | None + time_base: AVRational metadata: dict[str, str] class Container: diff --git a/av/container/output.pyi b/av/container/output.pyi index 961e7ad2d..5487fdf77 100644 --- a/av/container/output.pyi +++ b/av/container/output.pyi @@ -6,6 +6,7 @@ from av.audio import _AudioCodecName from av.audio.stream import AudioStream from av.codec.hwaccel import HWAccel from av.packet import Packet +from av.rational import AVRational from av.stream import AttachmentStream, DataStream, Stream from av.subtitles import _SubtitleCodecName from av.subtitles.stream import SubtitleStream @@ -29,7 +30,7 @@ class OutputContainer(Container): def add_stream( self, codec_name: _VideoCodecName, - rate: Fraction | int | None = None, + rate: AVRational | Fraction | int | None = None, options: dict[str, str] | None = None, hwaccel: HWAccel | None = None, **kwargs, @@ -38,7 +39,7 @@ class OutputContainer(Container): def add_stream( self, codec_name: _SubtitleCodecName, - rate: Fraction | int | None = None, + rate: AVRational | Fraction | int | None = None, options: dict[str, str] | None = None, **kwargs, ) -> SubtitleStream: ... @@ -46,7 +47,7 @@ class OutputContainer(Container): def add_stream( self, codec_name: str, - rate: Fraction | int | None = None, + rate: AVRational | Fraction | int | None = None, options: dict[str, str] | None = None, hwaccel: HWAccel | None = None, **kwargs, @@ -54,7 +55,7 @@ class OutputContainer(Container): def add_mux_stream( self, codec_name: str, - rate: Fraction | int | None = None, + rate: AVRational | Fraction | int | None = None, **kwargs, ) -> Stream: ... def add_stream_from_template( diff --git a/av/filter/context.py b/av/filter/context.py index 1ec3674d1..edfb93617 100644 --- a/av/filter/context.py +++ b/av/filter/context.py @@ -5,7 +5,7 @@ from cython.cimports.av.error import err_check from cython.cimports.av.filter.link import alloc_filter_pads from cython.cimports.av.frame import Frame -from cython.cimports.av.utils import avrational_to_fraction +from cython.cimports.av.rational import from_avrational from cython.cimports.av.video.frame import alloc_video_frame _cinit_sentinel = cython.declare(object, object()) @@ -162,9 +162,7 @@ def pull(self): err_check(res) frame._init_user_attributes() - frame.time_base = avrational_to_fraction( - cython.address(self.ptr.inputs[0].time_base) - ) + frame.time_base = from_avrational(self.ptr.inputs[0].time_base) return frame def process_command( diff --git a/av/filter/graph.py b/av/filter/graph.py index d2067facb..10a5ca2ab 100644 --- a/av/filter/graph.py +++ b/av/filter/graph.py @@ -1,5 +1,4 @@ import warnings -from fractions import Fraction import cython from cython.cimports.av.audio.format import AudioFormat @@ -8,6 +7,7 @@ from cython.cimports.av.error import err_check from cython.cimports.av.filter.context import FilterContext, wrap_filter_context from cython.cimports.av.filter.filter import Filter, wrap_filter +from cython.cimports.av.rational import AVRational from cython.cimports.av.video.format import VideoFormat from cython.cimports.av.video.frame import VideoFrame @@ -160,7 +160,7 @@ def add_buffer( "This is deprecated and may be removed in future releases.", DeprecationWarning, ) - time_base = Fraction(1, 1000) + time_base = AVRational(1, 1000) return self.add( "buffer", @@ -204,7 +204,7 @@ def add_abuffer( if layout is None and channels is None: raise ValueError("missing layout or channels") if time_base is None: - time_base = Fraction(1, sample_rate) + time_base = AVRational(1, sample_rate) kwargs = { "sample_rate": f"{sample_rate}", diff --git a/av/filter/graph.pyi b/av/filter/graph.pyi index 8f3231fc2..de28e5e30 100644 --- a/av/filter/graph.pyi +++ b/av/filter/graph.pyi @@ -5,6 +5,7 @@ from av.audio.format import AudioFormat from av.audio.frame import AudioFrame from av.audio.layout import AudioLayout from av.audio.stream import AudioStream +from av.rational import AVRational from av.video.format import VideoFormat from av.video.frame import VideoFrame from av.video.stream import VideoStream @@ -29,7 +30,7 @@ class Graph: height: int | None = None, format: VideoFormat | None = None, name: str | None = None, - time_base: Fraction | None = None, + time_base: AVRational | Fraction | None = None, ) -> FilterContext: ... def add_abuffer( self, @@ -39,7 +40,7 @@ class Graph: layout: AudioLayout | str | None = None, channels: int | None = None, name: str | None = None, - time_base: Fraction | None = None, + time_base: AVRational | Fraction | None = None, ) -> FilterContext: ... def set_audio_frame_size(self, frame_size: int) -> None: ... def push(self, frame: None | AudioFrame | VideoFrame, at: int = -1) -> None: ... diff --git a/av/frame.py b/av/frame.py index b6d6fd4be..809d19122 100644 --- a/av/frame.py +++ b/av/frame.py @@ -1,9 +1,9 @@ import cython from cython.cimports.av.error import err_check from cython.cimports.av.opaque import opaque_container +from cython.cimports.av.rational import from_avrational from cython.cimports.av.utils import ( avdict_to_dict, - avrational_to_fraction, to_avrational, ) @@ -141,10 +141,10 @@ def time_base(self): """ The unit of time (in fractional seconds) in which timestamps are expressed. - :type: fractions.Fraction | None + :type: AVRational """ if self._time_base.num: - return avrational_to_fraction(cython.address(self._time_base)) + return from_avrational(self._time_base) @time_base.setter def time_base(self, value): diff --git a/av/frame.pyi b/av/frame.pyi index caed23482..cd1d38d03 100644 --- a/av/frame.pyi +++ b/av/frame.pyi @@ -1,17 +1,17 @@ from fractions import Fraction -from typing import TypedDict -from av.sidedata.motionvectors import MotionVectors - -class SideData(TypedDict, total=False): - MOTION_VECTORS: MotionVectors +from av.rational import AVRational +from av.sidedata.sidedata import SideDataContainer class Frame: dts: int | None pts: int | None duration: int - time_base: Fraction | None - side_data: SideData + @property + def time_base(self) -> AVRational: ... + @time_base.setter + def time_base(self, value: AVRational | Fraction | int) -> None: ... + side_data: SideDataContainer opaque: object @property def metadata(self) -> dict[str, str]: ... diff --git a/av/packet.py b/av/packet.py index 3004b4690..d2be299c7 100644 --- a/av/packet.py +++ b/av/packet.py @@ -6,13 +6,12 @@ from cython.cimports.av.buffer import Buffer, ByteSource, bytesource from cython.cimports.av.error import err_check from cython.cimports.av.opaque import opaque_container -from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.av.rational import AVRational, from_avrational +from cython.cimports.av.utils import to_avrational from cython.cimports.cpython.ref import Py_DECREF, Py_INCREF from cython.cimports.libc.stdint import uint8_t from cython.cimports.libc.string import memcpy -from av.rational import AVRational - # Check https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/packet.h#L41 # for new additions in the future ffmpeg releases # Note: the order must follow that of the AVPacketSideDataType enum def @@ -338,9 +337,9 @@ def time_base(self): """ The unit of time (in fractional seconds) in which timestamps are expressed. - :type: fractions.Fraction + :type: AVRational """ - return avrational_to_fraction(cython.address(self.ptr.time_base)) + return from_avrational(self.ptr.time_base) @time_base.setter def time_base(self, value): diff --git a/av/packet.pyi b/av/packet.pyi index 1f005bf27..ce11d7040 100644 --- a/av/packet.pyi +++ b/av/packet.pyi @@ -79,7 +79,10 @@ StreamT = TypeVar("StreamT", bound=Stream) class Packet(Buffer, Generic[StreamT]): stream: StreamT stream_index: int - time_base: Fraction + @property + def time_base(self) -> AVRational: ... + @time_base.setter + def time_base(self, value: AVRational | Fraction | int) -> None: ... pts: int | None dts: int | None pos: int | None diff --git a/av/rational.pyi b/av/rational.pyi index 12716ca04..ac8a29b5b 100644 --- a/av/rational.pyi +++ b/av/rational.pyi @@ -1,6 +1,6 @@ from fractions import Fraction from numbers import Rational -from typing import Any +from typing import Any, overload class AVRational: num: int @@ -20,10 +20,22 @@ class AVRational: def __ge__(self, other: Any) -> bool: ... def __neg__(self) -> AVRational: ... def __mul__(self, other: Any) -> AVRational | Fraction | float: ... - def __rmul__(self, other: Any) -> Fraction | float: ... + @overload + def __rmul__(self, other: int | Fraction) -> Fraction: ... + @overload + def __rmul__(self, other: float) -> float: ... def __truediv__(self, other: Any) -> AVRational | Fraction | float: ... - def __rtruediv__(self, other: Any) -> Fraction | float: ... + @overload + def __rtruediv__(self, other: int | Fraction) -> Fraction: ... + @overload + def __rtruediv__(self, other: float) -> float: ... def __add__(self, other: Any) -> AVRational | Fraction | float: ... - def __radd__(self, other: Any) -> Fraction | float: ... + @overload + def __radd__(self, other: int | Fraction) -> Fraction: ... + @overload + def __radd__(self, other: float) -> float: ... def __sub__(self, other: Any) -> AVRational | Fraction | float: ... - def __rsub__(self, other: Any) -> Fraction | float: ... + @overload + def __rsub__(self, other: int | Fraction) -> Fraction: ... + @overload + def __rsub__(self, other: float) -> float: ... diff --git a/av/sidedata/encparams.py b/av/sidedata/encparams.py index d0e78e8a1..22d88deb8 100644 --- a/av/sidedata/encparams.py +++ b/av/sidedata/encparams.py @@ -134,6 +134,9 @@ def qp_map(self): @cython.cclass class VideoBlockParams: def __init__(self, video_enc_params: VideoEncParams, idx: cython.int) -> None: + if idx < 0 or idx >= video_enc_params.nb_blocks: + raise ValueError("Expected idx in range [0, nb_blocks)") + base: cython.pointer[uint8_t] = cython.cast( cython.pointer[uint8_t], video_enc_params.ptr.data ) diff --git a/av/sidedata/motionvectors.py b/av/sidedata/motionvectors.py index 369832ba6..0c3999309 100644 --- a/av/sidedata/motionvectors.py +++ b/av/sidedata/motionvectors.py @@ -24,14 +24,16 @@ def __len__(self): return self._len def __getitem__(self, index: cython.Py_ssize_t): + if index < 0: + index += self._len + if index < 0 or index >= self._len: + raise IndexError(index) + try: return self._vectors[index] except KeyError: pass - if index >= self._len: - raise IndexError(index) - vector = self._vectors[index] = MotionVector( _cinit_bypass_sentinel, self, index ) diff --git a/av/sidedata/sidedata.py b/av/sidedata/sidedata.py index 171bd4462..307524f0e 100644 --- a/av/sidedata/sidedata.py +++ b/av/sidedata/sidedata.py @@ -122,12 +122,20 @@ def __init__(self, frame: Frame): self._by_type[data.type] = data def __len__(self): - return len(self._by_index) + return len(self._by_type) def __iter__(self): - return iter(self._by_index) + """Iterate the :class:`Type` keys, as a mapping must. + + The values are reachable positionally too, via an integer or a slice. + """ + return iter(self._by_type) def __getitem__(self, key): + if isinstance(key, slice): + # Typed as list[SideData], so slice through an untyped alias. + entries: object = self._by_index + return entries[key] if isinstance(key, int): return self._by_index[key] if isinstance(key, str): diff --git a/av/sidedata/sidedata.pyi b/av/sidedata/sidedata.pyi index 5925aaff6..7d60c9476 100644 --- a/av/sidedata/sidedata.pyi +++ b/av/sidedata/sidedata.pyi @@ -45,7 +45,7 @@ class SideData(Buffer): class SideDataContainer(Mapping): frame: Frame def __len__(self) -> int: ... - def __iter__(self) -> Iterator[SideData]: ... + def __iter__(self) -> Iterator[Type]: ... @overload def __getitem__(self, key: str | int | Type) -> SideData: ... @overload diff --git a/av/stream.py b/av/stream.py index a564f6000..b696b9d98 100644 --- a/av/stream.py +++ b/av/stream.py @@ -4,9 +4,9 @@ from cython.cimports import libav as lib from cython.cimports.av.error import err_check from cython.cimports.av.index import wrap_index_entries +from cython.cimports.av.rational import from_avrational from cython.cimports.av.utils import ( avdict_to_dict, - avrational_to_fraction, dict_to_avdict, to_avrational, ) @@ -236,10 +236,10 @@ def time_base(self): """ The unit of time (in fractional seconds) in which timestamps are expressed. - :type: fractions.Fraction | None + :type: AVRational """ - return avrational_to_fraction(cython.address(self.ptr.time_base)) + return from_avrational(self.ptr.time_base) @property def start_time(self): diff --git a/av/stream.pyi b/av/stream.pyi index f9148021f..59485a624 100644 --- a/av/stream.pyi +++ b/av/stream.pyi @@ -2,6 +2,8 @@ from enum import IntEnum, IntFlag from fractions import Fraction from typing import Literal, cast +from av.rational import AVRational + from .codec import Codec, CodecContext from .container import Container from .index import IndexEntries @@ -48,10 +50,13 @@ class Stream: profile: str | None index: int options: dict[str, object] - time_base: Fraction | None - average_rate: Fraction | None - base_rate: Fraction | None - guessed_rate: Fraction | None + @property + def time_base(self) -> AVRational: ... + @time_base.setter + def time_base(self, value: AVRational | Fraction | int) -> None: ... + average_rate: AVRational + base_rate: AVRational + guessed_rate: AVRational start_time: int | None duration: int | None disposition: Disposition diff --git a/av/subtitles/subtitle.py b/av/subtitles/subtitle.py index 2d2b53df6..2e45b4e3a 100644 --- a/av/subtitles/subtitle.py +++ b/av/subtitles/subtitle.py @@ -254,7 +254,7 @@ def __getitem__(self, i): @cython.cclass class BitmapSubtitlePlane: def __cinit__(self, subtitle: BitmapSubtitle, index: cython.int): - if index >= 4: + if index < 0 or index >= 4: raise ValueError("BitmapSubtitles have only 4 planes") if not subtitle.ptr.linesize[index]: raise ValueError("plane does not exist") diff --git a/av/utils.pxd b/av/utils.pxd index 433592387..a81fcead2 100644 --- a/av/utils.pxd +++ b/av/utils.pxd @@ -4,6 +4,5 @@ cimport libav as lib cdef dict avdict_to_dict(lib.AVDictionary *input, str encoding, str errors) cdef void dict_to_avdict(lib.AVDictionary **dst, dict src, str encoding, str errors) -cdef object avrational_to_fraction(const lib.AVRational *input) cdef void to_avrational(object frac, lib.AVRational *input) cdef void check_ndarray(object array, object dtype, int ndim) diff --git a/av/utils.py b/av/utils.py index 026e8bfd5..c379825e5 100644 --- a/av/utils.py +++ b/av/utils.py @@ -1,6 +1,4 @@ # type: ignore -from fractions import Fraction - import cython from cython.cimports import libav as lib from cython.cimports.av.error import err_check @@ -44,15 +42,6 @@ def dict_to_avdict( ) -@cython.cfunc -def avrational_to_fraction( - input: cython.pointer[cython.const[lib.AVRational]], -) -> object: - if input.num and input.den: - return Fraction(input.num, input.den) - return None - - @cython.cfunc def to_avrational(frac: object, input: cython.pointer[lib.AVRational]) -> cython.void: input.num = frac.numerator diff --git a/av/video/codeccontext.py b/av/video/codeccontext.py index e36886896..4c003e869 100644 --- a/av/video/codeccontext.py +++ b/av/video/codeccontext.py @@ -5,7 +5,8 @@ from cython.cimports.av.error import err_check from cython.cimports.av.frame import Frame from cython.cimports.av.packet import Packet -from cython.cimports.av.utils import avrational_to_fraction, to_avrational +from cython.cimports.av.rational import from_avrational +from cython.cimports.av.utils import to_avrational from cython.cimports.av.video.format import VideoFormat, get_pix_fmt, get_video_format from cython.cimports.av.video.frame import VideoFrame, alloc_video_frame from cython.cimports.av.video.reformatter import VideoReformatter @@ -289,9 +290,9 @@ def framerate(self): """ The frame rate, in frames per second. - :type: fractions.Fraction + :type: AVRational """ - return avrational_to_fraction(cython.address(self.ptr.framerate)) + return from_avrational(self.ptr.framerate) @framerate.setter def framerate(self, value): @@ -326,7 +327,7 @@ def gop_size(self, value: cython.int): @property def sample_aspect_ratio(self): - return avrational_to_fraction(cython.address(self.ptr.sample_aspect_ratio)) + return from_avrational(self.ptr.sample_aspect_ratio) @sample_aspect_ratio.setter def sample_aspect_ratio(self, value): @@ -343,7 +344,7 @@ def display_aspect_ratio(self): 1024 * 1024, ) - return avrational_to_fraction(cython.address(dar)) + return from_avrational(dar) @property def has_b_frames(self): diff --git a/av/video/codeccontext.pyi b/av/video/codeccontext.pyi index 6f11c9aa9..056ae4629 100644 --- a/av/video/codeccontext.pyi +++ b/av/video/codeccontext.pyi @@ -4,6 +4,7 @@ from typing import Literal from av.codec.context import CodecContext from av.packet import Packet +from av.rational import AVRational from .format import VideoFormat from .frame import VideoFrame @@ -18,11 +19,20 @@ class VideoCodecContext(CodecContext): def sw_format(self) -> VideoFormat | None: ... @sw_format.setter def sw_format(self, value: str) -> None: ... - framerate: Fraction - rate: Fraction + @property + def framerate(self) -> AVRational: ... + @framerate.setter + def framerate(self, value: AVRational | Fraction | int) -> None: ... + @property + def rate(self) -> AVRational: ... + @rate.setter + def rate(self, value: AVRational | Fraction | int) -> None: ... gop_size: int - sample_aspect_ratio: Fraction | None - display_aspect_ratio: Fraction | None + @property + def sample_aspect_ratio(self) -> AVRational: ... + @sample_aspect_ratio.setter + def sample_aspect_ratio(self, value: AVRational | Fraction | int) -> None: ... + display_aspect_ratio: AVRational has_b_frames: bool reorder_depth: int max_b_frames: int diff --git a/av/video/format.py b/av/video/format.py index ce232ae6a..40d5f2fa1 100644 --- a/av/video/format.py +++ b/av/video/format.py @@ -146,6 +146,11 @@ def chroma_height(self, luma_height: cython.int = 0): @cython.cclass class VideoFormatComponent: def __cinit__(self, format: VideoFormat, index: cython.uint): + if index >= format.ptr.nb_components: + raise ValueError( + f"component index {index} out of range for {format!r}, which has " + f"{format.ptr.nb_components}" + ) self.format = format self.index = index self.ptr = cython.address(format.ptr.comp[index]) diff --git a/av/video/stream.py b/av/video/stream.py index e1de9b4f3..5bf83c5a2 100644 --- a/av/video/stream.py +++ b/av/video/stream.py @@ -1,8 +1,8 @@ import cython from cython.cimports import libav as lib from cython.cimports.av.packet import Packet +from cython.cimports.av.rational import from_avrational from cython.cimports.av.stream import Stream -from cython.cimports.av.utils import avrational_to_fraction from cython.cimports.av.video.frame import VideoFrame from cython.cimports.libc.stdint import int32_t from cython.cimports.libc.string import memcpy @@ -126,9 +126,9 @@ def average_rate(self): This is calculated when the file is opened by looking at the first few frames and averaging their rate. - :type: fractions.Fraction | None + :type: AVRational """ - return avrational_to_fraction(cython.address(self.ptr.avg_frame_rate)) + return from_avrational(self.ptr.avg_frame_rate) @property def base_rate(self): @@ -139,9 +139,9 @@ def base_rate(self): frames can be represented accurately. See :ffmpeg:`AVStream.r_frame_rate` for more. - :type: fractions.Fraction | None + :type: AVRational """ - return avrational_to_fraction(cython.address(self.ptr.r_frame_rate)) + return from_avrational(self.ptr.r_frame_rate) @property def guessed_rate(self): @@ -150,12 +150,12 @@ def guessed_rate(self): This is a wrapper around :ffmpeg:`av_guess_frame_rate`, and uses multiple heuristics to decide what is "the" frame rate. - :type: fractions.Fraction | None + :type: AVRational """ val: lib.AVRational = lib.av_guess_frame_rate( cython.NULL, self.ptr, cython.NULL ) - return avrational_to_fraction(cython.address(val)) + return from_avrational(val) @property def sample_aspect_ratio(self): @@ -164,12 +164,12 @@ def sample_aspect_ratio(self): This is a wrapper around :ffmpeg:`av_guess_sample_aspect_ratio`, and uses multiple heuristics to decide what is "the" sample aspect ratio. - :type: fractions.Fraction | None + :type: AVRational """ sar: lib.AVRational = lib.av_guess_sample_aspect_ratio( self.container.ptr, self.ptr, cython.NULL ) - return avrational_to_fraction(cython.address(sar)) + return from_avrational(sar) @property def display_aspect_ratio(self): @@ -177,7 +177,7 @@ def display_aspect_ratio(self): This is calculated from :meth:`.VideoStream.guessed_sample_aspect_ratio`. - :type: fractions.Fraction | None + :type: AVRational """ dar = cython.declare(lib.AVRational) lib.av_reduce( @@ -188,4 +188,4 @@ def display_aspect_ratio(self): 1024 * 1024, ) - return avrational_to_fraction(cython.address(dar)) + return from_avrational(dar) diff --git a/av/video/stream.pyi b/av/video/stream.pyi index e6797f413..ac5e926cf 100644 --- a/av/video/stream.pyi +++ b/av/video/stream.pyi @@ -4,6 +4,7 @@ from typing import Literal from av.codec.context import ThreadType from av.packet import Packet +from av.rational import AVRational from av.stream import Stream from .codeccontext import VideoCodecContext @@ -14,8 +15,8 @@ class VideoStream(Stream): bit_rate: int | None max_bit_rate: int | None bit_rate_tolerance: int - sample_aspect_ratio: Fraction | None - display_aspect_ratio: Fraction | None + sample_aspect_ratio: AVRational + display_aspect_ratio: AVRational codec_context: VideoCodecContext def encode(self, frame: VideoFrame | None = None) -> list[Packet]: ... @@ -34,8 +35,14 @@ class VideoStream(Stream): height: int bits_per_coded_sample: int pix_fmt: str | None - framerate: Fraction - rate: Fraction + @property + def framerate(self) -> AVRational: ... + @framerate.setter + def framerate(self, value: AVRational | Fraction | int) -> None: ... + @property + def rate(self) -> AVRational: ... + @rate.setter + def rate(self, value: AVRational | Fraction | int) -> None: ... gop_size: int has_b_frames: bool max_b_frames: int diff --git a/docs/api/time.rst b/docs/api/time.rst index bb0a7dc5b..963b84b9c 100644 --- a/docs/api/time.rst +++ b/docs/api/time.rst @@ -12,7 +12,7 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim .. testsetup:: import av - from fractions import Fraction + from av import AVRational path = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') def get_nth_packet_and_frame(fh, skip): @@ -27,13 +27,12 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim >>> fh = av.open(path) >>> video = fh.streams.video[0] - >>> video.time_base == Fraction(1, 25) + >>> video.time_base == AVRational(1, 25) True -Rational attributes like ``time_base`` may be unset. Test them by truthiness rather than -``is None`` — an unset value is always falsy, both today (``None``) and as PyAV -transitions these attributes to :class:`av.AVRational` (where unset is the falsy -``AVRational(0, 1)``):: +Rational attributes like ``time_base`` are :class:`av.AVRational` and may be unset. +Test them by truthiness rather than ``is None``: an unset value is the falsy +``AVRational(0, 1)``, never ``None``:: if not stream.time_base: ... # unset; pick a default @@ -55,12 +54,12 @@ In many cases a stream has a time base of ``1 / frame_rate``, and then its frame >>> p, f = get_nth_packet_and_frame(fh, skip=1) - >>> p.time_base == Fraction(1, 25) + >>> p.time_base == AVRational(1, 25) True >>> p.dts 1 - >>> f.time_base == Fraction(1, 25) + >>> f.time_base == AVRational(1, 25) True >>> f.pts 1 diff --git a/examples/numpy/generate_video_with_pts.py b/examples/numpy/generate_video_with_pts.py index 8de95b63f..bb39bd3d9 100644 --- a/examples/numpy/generate_video_with_pts.py +++ b/examples/numpy/generate_video_with_pts.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 import colorsys -from fractions import Fraction from math import lcm import numpy as np import av +from av import AVRational (width, height) = (640, 360) total_frames = 20 -fps = Fraction(30, 1) +fps = AVRational(30, 1) # MP4 stores a nonzero starting offset in an edit list using the movie timescale, # which defaults to 1000. Choose a timescale that can represent frame-aligned @@ -36,7 +36,7 @@ # 1/2 means half a second (would be okay for the delays we use below) # 1/30 means ~33 milliseconds # you should use the least fraction that makes sense for you -stream.codec_context.time_base = Fraction(1, fps) +stream.codec_context.time_base = AVRational(fps.den, fps.num) # this says when to show the next frame # (increment by how long the current frame will be shown) diff --git a/tests/test_audioframe.py b/tests/test_audioframe.py index c6bf87b3f..c364fb24e 100644 --- a/tests/test_audioframe.py +++ b/tests/test_audioframe.py @@ -4,6 +4,7 @@ import pytest from av import AudioFrame +from av.audio.plane import AudioPlane from .common import assertNdarraysEqual @@ -201,3 +202,14 @@ def test_ndarray_u8() -> None: assert frame.layout.name == layout assert frame.samples == 160 assertNdarraysEqual(frame.to_ndarray(), array) + + +def test_plane_index_out_of_range() -> None: + # Plane._buffer_ptr() indexes extended_data with this, so an unchecked + # index reads outside the frame. + for format, nb_planes in (("fltp", 2), ("s16", 1)): + frame = AudioFrame(format=format, layout="stereo", samples=1024) + assert len(frame.planes) == nb_planes + for bad in (-1, nb_planes, 100000000): + with pytest.raises(ValueError): + AudioPlane(frame, bad) diff --git a/tests/test_chapters.py b/tests/test_chapters.py index 21272f527..9cbaba00e 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -1,6 +1,5 @@ -from fractions import Fraction - import av +from av import AVRational from .common import fate_suite @@ -11,28 +10,28 @@ def test_chapters() -> None: "id": 1, "start": 0, "end": 5000, - "time_base": Fraction(1, 1000), + "time_base": AVRational(1, 1000), "metadata": {"title": "start"}, }, { "id": 2, "start": 5000, "end": 10500, - "time_base": Fraction(1, 1000), + "time_base": AVRational(1, 1000), "metadata": {"title": "Five Seconds"}, }, { "id": 3, "start": 10500, "end": 15000, - "time_base": Fraction(1, 1000), + "time_base": AVRational(1, 1000), "metadata": {"title": "Ten point 5 seconds"}, }, { "id": 4, "start": 15000, "end": 19849, - "time_base": Fraction(1, 1000), + "time_base": AVRational(1, 1000), "metadata": {"title": "15 sec - over soon"}, }, ] @@ -47,7 +46,7 @@ def test_set_chapters() -> None: "id": 1, "start": 0, "end": 5000, - "time_base": Fraction(1, 1000), + "time_base": AVRational(1, 1000), "metadata": {"title": "start"}, } ] diff --git a/tests/test_decode.py b/tests/test_decode.py index ecc22c676..35ce9feff 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -10,6 +10,7 @@ import av from av.sidedata.encparams import VideoEncParams +from av.sidedata.sidedata import Type from av.subtitles.subtitle import SubtitleSet from .common import TestCase, fate_suite @@ -183,6 +184,24 @@ def test_decoded_motion_vectors(self) -> None: assert vectors is not None and len(vectors) > 0 return + def test_motion_vector_index_bounds(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + stream = container.streams.video[0] + stream.codec_context.options = {"flags2": "+export_mvs"} + + for frame in container.decode(stream): + vectors = frame.side_data.get("MOTION_VECTORS") + if vectors is None or not len(vectors): + continue + + # Negative indices count from the end rather than reading off the + # front of the buffer. + assert vectors[-1].source == vectors[len(vectors) - 1].source + for bad in (len(vectors), -len(vectors) - 1, -(10**9)): + with pytest.raises(IndexError): + vectors[bad] + return + def test_decoded_motion_vectors_no_flag(self) -> None: container = av.open(fate_suite("h264/interlaced_crop.mp4")) stream = container.streams.video[0] @@ -281,6 +300,31 @@ def test_flush_decoded_video_frame_count(self) -> None: assert output_count == input_count + def test_side_data_mapping_protocol(self) -> None: + container = av.open(fate_suite("h264/interlaced_crop.mp4")) + stream = container.streams.video[0] + stream.codec_context.options = {"flags2": "+export_mvs"} + + for frame in container.decode(stream): + side_data = frame.side_data + if not len(side_data): + continue + + # Iteration yields keys, so the Mapping mixins work off it. + keys = list(side_data) + assert all(isinstance(key, Type) for key in keys) + assert keys == list(side_data.keys()) + assert len(keys) == len(side_data) + assert list(side_data.items()) == [(k, side_data[k]) for k in keys] + assert list(side_data.values()) == [side_data[k] for k in keys] + assert side_data == dict(side_data) + assert keys[0] in side_data + + # Values stay reachable positionally. + assert side_data[0] is side_data[keys[0]] + assert list(side_data[:]) == list(side_data.values()) + return + def test_no_side_data(self) -> None: container = av.open(fate_suite("h264/interlaced_crop.mp4")) frame = next(container.decode(video=0)) diff --git a/tests/test_encode.py b/tests/test_encode.py index 95942b4b7..a1eac8db5 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -121,7 +121,7 @@ def test_default_options(self) -> None: stream = output.add_stream("mpeg4") assert stream in output.streams.video assert stream.average_rate == Fraction(24, 1) - assert stream.time_base is None + assert not stream.time_base # codec context properties assert stream.format.height == 480 @@ -196,7 +196,7 @@ def test_default_options(self) -> None: with av.open(self.sandboxed("output.mov"), "w") as output: stream = output.add_stream("mp2") assert stream in output.streams.audio - assert stream.time_base is None + assert not stream.time_base # codec context properties assert stream.format.name == "s16" @@ -372,7 +372,7 @@ def test_set_id_and_time_base(self) -> None: assert stream.id == 1 # set time_base - assert stream.time_base is None + assert not stream.time_base stream.time_base = Fraction(1, 48000) assert stream.time_base == Fraction(1, 48000) diff --git a/tests/test_file_probing.py b/tests/test_file_probing.py index 8997227ae..11570deed 100644 --- a/tests/test_file_probing.py +++ b/tests/test_file_probing.py @@ -304,12 +304,12 @@ def test_stream_probing(self) -> None: assert stream.bit_rate is None assert stream.codec.long_name == "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10" assert stream.codec.name == "h264" - assert stream.display_aspect_ratio is None + assert not stream.display_aspect_ratio assert stream.format is None assert not stream.has_b_frames assert stream.height == 0 assert stream.max_bit_rate is None - assert stream.sample_aspect_ratio is None + assert not stream.sample_aspect_ratio assert stream.width == 0 assert stream.coded_width == 0 diff --git a/tests/test_videoformat.py b/tests/test_videoformat.py index 1e2e0c0f4..3247e4645 100644 --- a/tests/test_videoformat.py +++ b/tests/test_videoformat.py @@ -1,4 +1,7 @@ +import pytest + from av import VideoFormat +from av.video.format import VideoFormatComponent from .common import TestCase @@ -92,3 +95,9 @@ def test_pal8_inspection(self) -> None: fmt = VideoFormat("pal8", 640, 480) assert len(fmt.components) == 1 assert fmt.has_palette + + def test_component_index_out_of_range(self) -> None: + fmt = VideoFormat("yuv420p", 640, 480) + for bad in (len(fmt.components), 100000000): + with pytest.raises(ValueError): + VideoFormatComponent(fmt, bad)