Skip to content

Commit 61e4aa8

Browse files
committed
Add AVRational type
1 parent 50a71f8 commit 61e4aa8

9 files changed

Lines changed: 309 additions & 10 deletions

File tree

‎av/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices
2222
from av.format import ContainerFormat, formats_available
2323
from av.packet import Packet
24+
from av.rational import AVRational
2425
from av.error import * # noqa: F403; This is limited to exception types.
2526
from av.video.codeccontext import VideoCodecContext
2627
from av.video.format import VideoFormat

‎av/codec/codec.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from cython.cimports import libav as lib
55
from cython.cimports.av.audio.format import get_audio_format
66
from cython.cimports.av.codec.hwaccel import wrap_hwconfig
7+
from cython.cimports.av.rational import from_avrational
78
from cython.cimports.av.utils import avrational_to_fraction
89
from cython.cimports.av.video.format import VideoFormat, get_pix_fmt, get_video_format
910
from cython.cimports.libc.stdlib import free, malloc
@@ -193,7 +194,7 @@ def id(self):
193194

194195
@property
195196
def frame_rates(self):
196-
"""A list of supported frame rates (:class:`fractions.Fraction`), or ``None``."""
197+
"""A list of supported frame rates (:class:`av.AVRational`), or ``None``."""
197198
out: cython.pointer[cython.const[cython.void]] = cython.NULL
198199
num: cython.int = 0
199200
lib.avcodec_get_supported_config(
@@ -207,7 +208,7 @@ def frame_rates(self):
207208
if not out:
208209
return
209210
rates = cython.cast(cython.pointer[lib.AVRational], out)
210-
return [avrational_to_fraction(cython.address(rates[i])) for i in range(num)]
211+
return [from_avrational(rates[i]) for i in range(num)]
211212

212213
@property
213214
def audio_rates(self):

‎av/codec/codec.pyi‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from collections.abc import Sequence
22
from enum import Flag, IntEnum, IntFlag
3-
from fractions import Fraction
43
from typing import ClassVar, Literal, cast, overload
54

65
from av.audio.codeccontext import AudioCodecContext
76
from av.audio.format import AudioFormat
7+
from av.rational import AVRational
88
from av.subtitles.codeccontext import SubtitleCodecContext
99
from av.video.codeccontext import VideoCodecContext
1010
from av.video.format import VideoFormat
@@ -75,7 +75,7 @@ class Codec:
7575
) -> Literal["video", "audio", "data", "subtitle", "attachment", "unknown"]: ...
7676
@property
7777
def id(self) -> int: ...
78-
frame_rates: list[Fraction] | None
78+
frame_rates: list[AVRational] | None
7979
audio_rates: list[int] | None
8080
video_formats: list[VideoFormat] | None
8181
audio_formats: list[AudioFormat] | None

‎av/rational.pxd‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
cimport libav as lib
2+
3+
4+
cdef class AVRational:
5+
cdef readonly int num
6+
cdef readonly int den
7+
8+
cdef lib.AVRational _q(self)
9+
10+
11+
cdef AVRational from_avrational(lib.AVRational q)

‎av/rational.py‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# type: ignore
2+
from fractions import Fraction
3+
from numbers import Rational
4+
5+
import cython
6+
from cython.cimports import libav as lib
7+
8+
_INT32_MAX: cython.longlong = 2147483647
9+
10+
11+
@cython.cclass
12+
class AVRational:
13+
"""
14+
An exact rational number stored as two int32s, mirroring FFmpeg's
15+
``AVRational``.
16+
17+
Values are always reduced to lowest terms with a positive denominator.
18+
Arithmetic between two :class:`AVRational` uses FFmpeg's ``av_mul_q``
19+
family: intermediates are computed in int64, then reduced back to int32,
20+
**approximating** the result if it does not fit. Arithmetic with other
21+
numeric types promotes to :class:`fractions.Fraction` (exact).
22+
23+
Following FFmpeg, a zero denominator is allowed: ``1/0``, ``-1/0``
24+
(infinities) and ``0/0`` (undefined) exist and, like the unset value
25+
``AVRational(0, 1)``, are falsy — so ``if rate:`` covers every
26+
not-a-real-value case that used to be ``None``.
27+
28+
Every PyAV setter that accepts a :class:`fractions.Fraction` (e.g.
29+
``stream.time_base``, ``codec_context.framerate``) also accepts an
30+
:class:`AVRational`.
31+
"""
32+
33+
def __init__(self, num=0, den=1):
34+
if den == 1 and isinstance(num, Rational):
35+
num, den = num.numerator, num.denominator
36+
n64: cython.longlong = num
37+
d64: cython.longlong = den
38+
n: cython.int
39+
d: cython.int
40+
if not lib.av_reduce(
41+
cython.address(n), cython.address(d), n64, d64, _INT32_MAX
42+
):
43+
raise OverflowError(f"{num}/{den} cannot be reduced to fit in int32")
44+
self.num = n
45+
self.den = d
46+
47+
@cython.cfunc
48+
def _q(self) -> lib.AVRational:
49+
q: lib.AVRational
50+
q.num = self.num
51+
q.den = self.den
52+
return q
53+
54+
@property
55+
def numerator(self):
56+
return self.num
57+
58+
@property
59+
def denominator(self):
60+
return self.den
61+
62+
def __repr__(self):
63+
return f"AVRational({self.num}, {self.den})"
64+
65+
def __str__(self):
66+
return f"{self.num}/{self.den}"
67+
68+
def __bool__(self):
69+
return self.num != 0 and self.den != 0
70+
71+
def __float__(self):
72+
if self.den == 0:
73+
return float("nan") if self.num == 0 else self.num * float("inf")
74+
return self.num / self.den
75+
76+
def __hash__(self):
77+
if self.den == 0:
78+
return hash((self.num, 0))
79+
return hash(Fraction(self.num, self.den))
80+
81+
def __reduce__(self):
82+
return (AVRational, (self.num, self.den))
83+
84+
def __eq__(self, other):
85+
if isinstance(other, AVRational):
86+
o: AVRational = other
87+
return self.num == o.num and self.den == o.den
88+
if self.den == 0:
89+
return False
90+
return Fraction(self.num, self.den) == other
91+
92+
def __lt__(self, other):
93+
return Fraction(self.num, self.den) < other
94+
95+
def __le__(self, other):
96+
return Fraction(self.num, self.den) <= other
97+
98+
def __gt__(self, other):
99+
return Fraction(self.num, self.den) > other
100+
101+
def __ge__(self, other):
102+
return Fraction(self.num, self.den) >= other
103+
104+
def __neg__(self):
105+
return AVRational(-self.num, self.den)
106+
107+
def __mul__(self, other):
108+
if isinstance(other, AVRational):
109+
o: AVRational = other
110+
return from_avrational(lib.av_mul_q(self._q(), o._q()))
111+
return Fraction(self.num, self.den) * other
112+
113+
def __rmul__(self, other):
114+
return other * Fraction(self.num, self.den)
115+
116+
def __truediv__(self, other):
117+
if isinstance(other, AVRational):
118+
o: AVRational = other
119+
if o.num == 0:
120+
raise ZeroDivisionError(f"{self} / {other}")
121+
return from_avrational(lib.av_div_q(self._q(), o._q()))
122+
return Fraction(self.num, self.den) / other
123+
124+
def __rtruediv__(self, other):
125+
return other / Fraction(self.num, self.den)
126+
127+
def __add__(self, other):
128+
if isinstance(other, AVRational):
129+
o: AVRational = other
130+
return from_avrational(lib.av_add_q(self._q(), o._q()))
131+
return Fraction(self.num, self.den) + other
132+
133+
def __radd__(self, other):
134+
return other + Fraction(self.num, self.den)
135+
136+
def __sub__(self, other):
137+
if isinstance(other, AVRational):
138+
o: AVRational = other
139+
return from_avrational(lib.av_sub_q(self._q(), o._q()))
140+
return Fraction(self.num, self.den) - other
141+
142+
def __rsub__(self, other):
143+
return other - Fraction(self.num, self.den)
144+
145+
146+
@cython.cfunc
147+
def from_avrational(q: lib.AVRational) -> AVRational:
148+
obj: AVRational = AVRational.__new__(AVRational)
149+
# FFmpeg does not guarantee reduced form; our invariant requires it.
150+
lib.av_reduce(
151+
cython.address(obj.num), cython.address(obj.den), q.num, q.den, _INT32_MAX
152+
)
153+
return obj
154+
155+
156+
Rational.register(AVRational)

‎av/rational.pyi‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from fractions import Fraction
2+
from numbers import Rational
3+
from typing import Any
4+
5+
class AVRational:
6+
num: int
7+
den: int
8+
def __init__(self, num: int | Rational = 0, den: int = 1) -> None: ...
9+
@property
10+
def numerator(self) -> int: ...
11+
@property
12+
def denominator(self) -> int: ...
13+
def __bool__(self) -> bool: ...
14+
def __float__(self) -> float: ...
15+
def __hash__(self) -> int: ...
16+
def __eq__(self, other: Any) -> bool: ...
17+
def __lt__(self, other: Any) -> bool: ...
18+
def __le__(self, other: Any) -> bool: ...
19+
def __gt__(self, other: Any) -> bool: ...
20+
def __ge__(self, other: Any) -> bool: ...
21+
def __neg__(self) -> AVRational: ...
22+
def __mul__(self, other: Any) -> AVRational | Fraction | float: ...
23+
def __rmul__(self, other: Any) -> Fraction | float: ...
24+
def __truediv__(self, other: Any) -> AVRational | Fraction | float: ...
25+
def __rtruediv__(self, other: Any) -> Fraction | float: ...
26+
def __add__(self, other: Any) -> AVRational | Fraction | float: ...
27+
def __radd__(self, other: Any) -> Fraction | float: ...
28+
def __sub__(self, other: Any) -> AVRational | Fraction | float: ...
29+
def __rsub__(self, other: Any) -> Fraction | float: ...

‎docs/api/time.rst‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Time is expressed as integer multiples of arbitrary units of time called a ``tim
1212
.. testsetup::
1313

1414
import av
15+
from fractions import Fraction
1516
path = av.datasets.curated('pexels/time-lapse-video-of-night-sky-857195.mp4')
1617

1718
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
2627
>>> fh = av.open(path)
2728
>>> video = fh.streams.video[0]
2829

29-
>>> video.time_base
30-
Fraction(1, 25)
30+
>>> video.time_base == Fraction(1, 25)
31+
True
32+
33+
Rational attributes like ``time_base`` may be unset. Test them by truthiness rather than
34+
``is None`` — an unset value is always falsy, both today (``None``) and as PyAV
35+
transitions these attributes to :class:`av.AVRational` (where unset is the falsy
36+
``AVRational(0, 1)``)::
37+
38+
if not stream.time_base:
39+
... # unset; pick a default
3140

3241
Attributes that represent time on those objects will be in that object's ``time_base``:
3342

@@ -46,13 +55,13 @@ In many cases a stream has a time base of ``1 / frame_rate``, and then its frame
4655

4756
>>> p, f = get_nth_packet_and_frame(fh, skip=1)
4857

49-
>>> p.time_base
50-
Fraction(1, 25)
58+
>>> p.time_base == Fraction(1, 25)
59+
True
5160
>>> p.dts
5261
1
5362

54-
>>> f.time_base
55-
Fraction(1, 25)
63+
>>> f.time_base == Fraction(1, 25)
64+
True
5665
>>> f.pts
5766
1
5867

‎include/avutil.pxd‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ cdef extern from "libavutil/pixdesc.h" nogil:
366366

367367
cdef extern from "libavutil/rational.h" nogil:
368368
cdef int av_reduce(int *dst_num, int *dst_den, int64_t num, int64_t den, int64_t max)
369+
cdef AVRational av_mul_q(AVRational b, AVRational c)
370+
cdef AVRational av_div_q(AVRational b, AVRational c)
371+
cdef AVRational av_add_q(AVRational b, AVRational c)
372+
cdef AVRational av_sub_q(AVRational b, AVRational c)
369373

370374
cdef extern from "libavutil/samplefmt.h" nogil:
371375
cdef enum AVSampleFormat:

‎tests/test_rational.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import pickle
2+
from fractions import Fraction
3+
4+
import pytest
5+
6+
from av import AVRational
7+
8+
9+
def test_construction() -> None:
10+
r = AVRational(2, 4)
11+
assert (r.num, r.den) == (1, 2)
12+
assert (AVRational(1, -2).num, AVRational(1, -2).den) == (-1, 2)
13+
assert AVRational(Fraction(30000, 1001)) == AVRational(30000, 1001)
14+
assert AVRational(10**10, 2 * 10**10) == AVRational(1, 2)
15+
with pytest.raises(OverflowError):
16+
AVRational(2**31, 3)
17+
18+
19+
def test_unset_is_falsy() -> None:
20+
assert not AVRational()
21+
assert not AVRational(0, 1)
22+
assert AVRational(1, 25)
23+
24+
25+
def test_zero_denominator() -> None:
26+
inf = AVRational(1, 0)
27+
assert not inf and not AVRational(-1, 0) and not AVRational(0, 0)
28+
assert (AVRational(5, 0).num, AVRational(5, 0).den) == (1, 0)
29+
assert (AVRational(-7, 0).num, AVRational(-7, 0).den) == (-1, 0)
30+
assert inf == AVRational(2, 0)
31+
assert inf != AVRational(0, 0) and inf != Fraction(1, 2) and inf != 1
32+
assert float(inf) == float("inf")
33+
assert float(AVRational(-1, 0)) == float("-inf")
34+
assert str(float(AVRational(0, 0))) == "nan"
35+
assert hash(inf) == hash(AVRational(1, 0))
36+
assert pickle.loads(pickle.dumps(inf)) == inf
37+
38+
39+
def test_fraction_interop() -> None:
40+
r = AVRational(1, 2)
41+
assert r == Fraction(1, 2)
42+
assert Fraction(1, 2) == r
43+
assert hash(r) == hash(Fraction(1, 2))
44+
assert r < Fraction(2, 3) < AVRational(3, 4)
45+
assert r * Fraction(1, 3) == Fraction(1, 6)
46+
assert Fraction(1, 3) * r == Fraction(1, 6)
47+
assert 2 * r == 1
48+
assert r + 1 == Fraction(3, 2)
49+
assert 1 - r == Fraction(1, 2)
50+
assert float(r) == 0.5
51+
52+
53+
def test_avrational_arithmetic() -> None:
54+
a = AVRational(1, 25)
55+
b = AVRational(1, 2)
56+
assert a * b == AVRational(1, 50)
57+
assert isinstance(a * b, AVRational)
58+
assert a + b == AVRational(27, 50)
59+
assert b - a == AVRational(23, 50)
60+
assert a / b == AVRational(2, 25)
61+
assert -a == AVRational(-1, 25)
62+
with pytest.raises(ZeroDivisionError):
63+
a / AVRational(0, 1)
64+
huge = AVRational(1, 2**30) * AVRational(1, 2**30)
65+
assert float(huge) == pytest.approx(2.0**-60, rel=1e-6)
66+
67+
68+
def test_setters_accept_avrational() -> None:
69+
import av
70+
71+
cc = av.codec.CodecContext.create("mpeg4", "w")
72+
cc.time_base = AVRational(1001, 30000) # type: ignore[assignment]
73+
assert cc.time_base == Fraction(1001, 30000)
74+
75+
76+
def test_codec_frame_rates() -> None:
77+
import av
78+
79+
rates = av.Codec("mpeg2video", "w").frame_rates
80+
assert rates and all(isinstance(r, AVRational) for r in rates)
81+
assert AVRational(30000, 1001) in rates
82+
83+
84+
def test_pickle_and_repr() -> None:
85+
r = AVRational(30000, 1001)
86+
assert pickle.loads(pickle.dumps(r)) == r
87+
assert repr(r) == "AVRational(30000, 1001)"
88+
assert str(r) == "30000/1001"

0 commit comments

Comments
 (0)