From 2727e254d58775c5131831b17566023b358315cd Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Wed, 15 Jul 2026 13:31:25 -0400 Subject: [PATCH] Add AVRational type --- av/__init__.py | 1 + av/codec/codec.py | 5 +- av/codec/codec.pyi | 4 +- av/rational.pxd | 11 +++ av/rational.py | 156 +++++++++++++++++++++++++++++++++++++++++ av/rational.pyi | 29 ++++++++ docs/api/time.rst | 21 ++++-- include/avutil.pxd | 4 ++ tests/test_rational.py | 88 +++++++++++++++++++++++ 9 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 av/rational.pxd create mode 100644 av/rational.py create mode 100644 av/rational.pyi create mode 100644 tests/test_rational.py diff --git a/av/__init__.py b/av/__init__.py index 9d8148082..964b93891 100644 --- a/av/__init__.py +++ b/av/__init__.py @@ -21,6 +21,7 @@ from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices from av.format import ContainerFormat, formats_available from av.packet import Packet +from av.rational import AVRational from av.error import * # noqa: F403; This is limited to exception types. from av.video.codeccontext import VideoCodecContext from av.video.format import VideoFormat diff --git a/av/codec/codec.py b/av/codec/codec.py index edda0c29d..0e8b958ec 100644 --- a/av/codec/codec.py +++ b/av/codec/codec.py @@ -4,6 +4,7 @@ from cython.cimports import libav as lib from cython.cimports.av.audio.format import get_audio_format from cython.cimports.av.codec.hwaccel import 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 @@ -193,7 +194,7 @@ def id(self): @property def frame_rates(self): - """A list of supported frame rates (:class:`fractions.Fraction`), or ``None``.""" + """A list of supported frame rates (:class:`av.AVRational`), or ``None``.""" out: cython.pointer[cython.const[cython.void]] = cython.NULL num: cython.int = 0 lib.avcodec_get_supported_config( @@ -207,7 +208,7 @@ def frame_rates(self): if not out: return rates = cython.cast(cython.pointer[lib.AVRational], out) - return [avrational_to_fraction(cython.address(rates[i])) for i in range(num)] + return [from_avrational(rates[i]) for i in range(num)] @property def audio_rates(self): diff --git a/av/codec/codec.pyi b/av/codec/codec.pyi index 8631790b2..be3953dd3 100644 --- a/av/codec/codec.pyi +++ b/av/codec/codec.pyi @@ -1,10 +1,10 @@ from collections.abc import Sequence from enum import Flag, IntEnum, IntFlag -from fractions import Fraction from typing import ClassVar, Literal, cast, overload from av.audio.codeccontext import AudioCodecContext from av.audio.format import AudioFormat +from av.rational import AVRational from av.subtitles.codeccontext import SubtitleCodecContext from av.video.codeccontext import VideoCodecContext from av.video.format import VideoFormat @@ -75,7 +75,7 @@ class Codec: ) -> Literal["video", "audio", "data", "subtitle", "attachment", "unknown"]: ... @property def id(self) -> int: ... - frame_rates: list[Fraction] | None + frame_rates: list[AVRational] | None audio_rates: list[int] | None video_formats: list[VideoFormat] | None audio_formats: list[AudioFormat] | None diff --git a/av/rational.pxd b/av/rational.pxd new file mode 100644 index 000000000..a416fea49 --- /dev/null +++ b/av/rational.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + + +cdef class AVRational: + cdef readonly int num + cdef readonly int den + + cdef lib.AVRational _q(self) + + +cdef AVRational from_avrational(lib.AVRational q) diff --git a/av/rational.py b/av/rational.py new file mode 100644 index 000000000..87ce7ade9 --- /dev/null +++ b/av/rational.py @@ -0,0 +1,156 @@ +# type: ignore +from fractions import Fraction +from numbers import Rational + +import cython +from cython.cimports import libav as lib + +_INT32_MAX: cython.longlong = 2147483647 + + +@cython.cclass +class AVRational: + """ + An exact rational number stored as two int32s, mirroring FFmpeg's + ``AVRational``. + + Values are always reduced to lowest terms with a positive denominator. + Arithmetic between two :class:`AVRational` uses FFmpeg's ``av_mul_q`` + family: intermediates are computed in int64, then reduced back to int32, + **approximating** the result if it does not fit. Arithmetic with other + numeric types promotes to :class:`fractions.Fraction` (exact). + + Following FFmpeg, a zero denominator is allowed: ``1/0``, ``-1/0`` + (infinities) and ``0/0`` (undefined) exist and, like the unset value + ``AVRational(0, 1)``, are falsy — so ``if rate:`` covers every + not-a-real-value case that used to be ``None``. + + Every PyAV setter that accepts a :class:`fractions.Fraction` (e.g. + ``stream.time_base``, ``codec_context.framerate``) also accepts an + :class:`AVRational`. + """ + + def __init__(self, num=0, den=1): + if den == 1 and isinstance(num, Rational): + num, den = num.numerator, num.denominator + n64: cython.longlong = num + d64: cython.longlong = den + n: cython.int + d: cython.int + if not lib.av_reduce( + cython.address(n), cython.address(d), n64, d64, _INT32_MAX + ): + raise OverflowError(f"{num}/{den} cannot be reduced to fit in int32") + self.num = n + self.den = d + + @cython.cfunc + def _q(self) -> lib.AVRational: + q: lib.AVRational + q.num = self.num + q.den = self.den + return q + + @property + def numerator(self): + return self.num + + @property + def denominator(self): + return self.den + + def __repr__(self): + return f"AVRational({self.num}, {self.den})" + + def __str__(self): + return f"{self.num}/{self.den}" + + def __bool__(self): + return self.num != 0 and self.den != 0 + + def __float__(self): + if self.den == 0: + return float("nan") if self.num == 0 else self.num * float("inf") + return self.num / self.den + + def __hash__(self): + if self.den == 0: + return hash((self.num, 0)) + return hash(Fraction(self.num, self.den)) + + def __reduce__(self): + return (AVRational, (self.num, self.den)) + + def __eq__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return self.num == o.num and self.den == o.den + if self.den == 0: + return False + return Fraction(self.num, self.den) == other + + def __lt__(self, other): + return Fraction(self.num, self.den) < other + + def __le__(self, other): + return Fraction(self.num, self.den) <= other + + def __gt__(self, other): + return Fraction(self.num, self.den) > other + + def __ge__(self, other): + return Fraction(self.num, self.den) >= other + + def __neg__(self): + return AVRational(-self.num, self.den) + + def __mul__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_mul_q(self._q(), o._q())) + return Fraction(self.num, self.den) * other + + def __rmul__(self, other): + return other * Fraction(self.num, self.den) + + def __truediv__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + if o.num == 0: + raise ZeroDivisionError(f"{self} / {other}") + return from_avrational(lib.av_div_q(self._q(), o._q())) + return Fraction(self.num, self.den) / other + + def __rtruediv__(self, other): + return other / Fraction(self.num, self.den) + + def __add__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_add_q(self._q(), o._q())) + return Fraction(self.num, self.den) + other + + def __radd__(self, other): + return other + Fraction(self.num, self.den) + + def __sub__(self, other): + if isinstance(other, AVRational): + o: AVRational = other + return from_avrational(lib.av_sub_q(self._q(), o._q())) + return Fraction(self.num, self.den) - other + + def __rsub__(self, other): + return other - Fraction(self.num, self.den) + + +@cython.cfunc +def from_avrational(q: lib.AVRational) -> AVRational: + obj: AVRational = AVRational.__new__(AVRational) + # FFmpeg does not guarantee reduced form; our invariant requires it. + lib.av_reduce( + cython.address(obj.num), cython.address(obj.den), q.num, q.den, _INT32_MAX + ) + return obj + + +Rational.register(AVRational) diff --git a/av/rational.pyi b/av/rational.pyi new file mode 100644 index 000000000..12716ca04 --- /dev/null +++ b/av/rational.pyi @@ -0,0 +1,29 @@ +from fractions import Fraction +from numbers import Rational +from typing import Any + +class AVRational: + num: int + den: int + def __init__(self, num: int | Rational = 0, den: int = 1) -> None: ... + @property + def numerator(self) -> int: ... + @property + def denominator(self) -> int: ... + def __bool__(self) -> bool: ... + def __float__(self) -> float: ... + def __hash__(self) -> int: ... + def __eq__(self, other: Any) -> bool: ... + def __lt__(self, other: Any) -> bool: ... + def __le__(self, other: Any) -> bool: ... + def __gt__(self, other: Any) -> bool: ... + 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: ... + def __truediv__(self, other: Any) -> AVRational | Fraction | float: ... + def __rtruediv__(self, other: Any) -> Fraction | float: ... + def __add__(self, other: Any) -> AVRational | Fraction | float: ... + def __radd__(self, other: Any) -> Fraction | float: ... + def __sub__(self, other: Any) -> AVRational | Fraction | float: ... + def __rsub__(self, other: Any) -> Fraction | float: ... diff --git a/docs/api/time.rst b/docs/api/time.rst index a063e876e..bb0a7dc5b 100644 --- a/docs/api/time.rst +++ b/docs/api/time.rst @@ -12,6 +12,7 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim .. testsetup:: import av + from fractions import Fraction path = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4') def get_nth_packet_and_frame(fh, skip): @@ -26,8 +27,16 @@ 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 == Fraction(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)``):: + + if not stream.time_base: + ... # unset; pick a default Attributes that represent time on those objects will be in that object's ``time_base``: @@ -46,13 +55,13 @@ 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 == Fraction(1, 25) + True >>> p.dts 1 - >>> f.time_base - Fraction(1, 25) + >>> f.time_base == Fraction(1, 25) + True >>> f.pts 1 diff --git a/include/avutil.pxd b/include/avutil.pxd index 8911adcc0..615b6979d 100644 --- a/include/avutil.pxd +++ b/include/avutil.pxd @@ -366,6 +366,10 @@ cdef extern from "libavutil/pixdesc.h" nogil: cdef extern from "libavutil/rational.h" nogil: cdef int av_reduce(int *dst_num, int *dst_den, int64_t num, int64_t den, int64_t max) + cdef AVRational av_mul_q(AVRational b, AVRational c) + cdef AVRational av_div_q(AVRational b, AVRational c) + cdef AVRational av_add_q(AVRational b, AVRational c) + cdef AVRational av_sub_q(AVRational b, AVRational c) cdef extern from "libavutil/samplefmt.h" nogil: cdef enum AVSampleFormat: diff --git a/tests/test_rational.py b/tests/test_rational.py new file mode 100644 index 000000000..f534e7d68 --- /dev/null +++ b/tests/test_rational.py @@ -0,0 +1,88 @@ +import pickle +from fractions import Fraction + +import pytest + +from av import AVRational + + +def test_construction() -> None: + r = AVRational(2, 4) + assert (r.num, r.den) == (1, 2) + assert (AVRational(1, -2).num, AVRational(1, -2).den) == (-1, 2) + assert AVRational(Fraction(30000, 1001)) == AVRational(30000, 1001) + assert AVRational(10**10, 2 * 10**10) == AVRational(1, 2) + with pytest.raises(OverflowError): + AVRational(2**31, 3) + + +def test_unset_is_falsy() -> None: + assert not AVRational() + assert not AVRational(0, 1) + assert AVRational(1, 25) + + +def test_zero_denominator() -> None: + inf = AVRational(1, 0) + assert not inf and not AVRational(-1, 0) and not AVRational(0, 0) + assert (AVRational(5, 0).num, AVRational(5, 0).den) == (1, 0) + assert (AVRational(-7, 0).num, AVRational(-7, 0).den) == (-1, 0) + assert inf == AVRational(2, 0) + assert inf != AVRational(0, 0) and inf != Fraction(1, 2) and inf != 1 + assert float(inf) == float("inf") + assert float(AVRational(-1, 0)) == float("-inf") + assert str(float(AVRational(0, 0))) == "nan" + assert hash(inf) == hash(AVRational(1, 0)) + assert pickle.loads(pickle.dumps(inf)) == inf + + +def test_fraction_interop() -> None: + r = AVRational(1, 2) + assert r == Fraction(1, 2) + assert Fraction(1, 2) == r + assert hash(r) == hash(Fraction(1, 2)) + assert r < Fraction(2, 3) < AVRational(3, 4) + assert r * Fraction(1, 3) == Fraction(1, 6) + assert Fraction(1, 3) * r == Fraction(1, 6) + assert 2 * r == 1 + assert r + 1 == Fraction(3, 2) + assert 1 - r == Fraction(1, 2) + assert float(r) == 0.5 + + +def test_avrational_arithmetic() -> None: + a = AVRational(1, 25) + b = AVRational(1, 2) + assert a * b == AVRational(1, 50) + assert isinstance(a * b, AVRational) + assert a + b == AVRational(27, 50) + assert b - a == AVRational(23, 50) + assert a / b == AVRational(2, 25) + assert -a == AVRational(-1, 25) + with pytest.raises(ZeroDivisionError): + a / AVRational(0, 1) + huge = AVRational(1, 2**30) * AVRational(1, 2**30) + assert float(huge) == pytest.approx(2.0**-60, rel=1e-6) + + +def test_setters_accept_avrational() -> None: + import av + + cc = av.codec.CodecContext.create("mpeg4", "w") + cc.time_base = AVRational(1001, 30000) # type: ignore[assignment] + assert cc.time_base == Fraction(1001, 30000) + + +def test_codec_frame_rates() -> None: + import av + + rates = av.Codec("mpeg2video", "w").frame_rates + assert rates and all(isinstance(r, AVRational) for r in rates) + assert AVRational(30000, 1001) in rates + + +def test_pickle_and_repr() -> None: + r = AVRational(30000, 1001) + assert pickle.loads(pickle.dumps(r)) == r + assert repr(r) == "AVRational(30000, 1001)" + assert str(r) == "30000/1001"