Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions av/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions av/codec/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions av/codec/codec.pyi
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions av/rational.pxd
Original file line number Diff line number Diff line change
@@ -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)
156 changes: 156 additions & 0 deletions av/rational.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions av/rational.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
21 changes: 15 additions & 6 deletions docs/api/time.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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``:

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions include/avutil.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
88 changes: 88 additions & 0 deletions tests/test_rational.py
Original file line number Diff line number Diff line change
@@ -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"
Loading