From 4f6e13f9cb862810aec9e08884035709bfddb64e Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Sat, 24 Oct 2026 23:01:48 -0400 Subject: [PATCH] Add `Frame.best_effort_timestamp` For inputs that store only DTS, such as AVI, the PTS that `genpts` generates can go backwards when the video has B-frames. Expose FFmpeg's own estimate, which falls back to DTS when PTS is unreliable, and document that input containers set `genpts` and how to turn it off. Closes #2477 --- av/container/core.py | 3 +++ av/frame.py | 17 +++++++++++++++++ av/frame.pyi | 2 ++ include/avcodec.pxd | 1 + tests/test_decode.py | 9 +++++++++ 5 files changed, 32 insertions(+) diff --git a/av/container/core.py b/av/container/core.py index 01c392d93..39f88c4a9 100755 --- a/av/container/core.py +++ b/av/container/core.py @@ -496,6 +496,9 @@ def open( :param str format: Specific format to use. Defaults to autodect. :param dict options: Options to pass to the container and all streams. :param dict container_options: Options to pass to the container. + Input containers set the ``genpts`` flag, which fills in missing packet PTS. + Pass ``{"fflags": "-genpts"}`` to turn it off. See also + :attr:`.Frame.best_effort_timestamp`. :param int buffer_size: Size of buffer for Python input/output operations in bytes. Honored only when ``file`` is a file-like object. Defaults to 32768 (32k). :param timeout: How many seconds to wait for data before giving up, as a float, or a diff --git a/av/frame.py b/av/frame.py index 9e6a87a62..9c78ea73c 100644 --- a/av/frame.py +++ b/av/frame.py @@ -114,6 +114,23 @@ def pts(self, value): else: self.ptr.pts = value + @property + def best_effort_timestamp(self): + """ + The presentation timestamp estimated by the decoder, in :attr:`time_base` units. + + This is the timestamp FFmpeg's own tools use. It is :attr:`pts`, unless the + PTS values have been missing or out of order more often than the DTS + values, in which case it is :attr:`dts`. Prefer it over :attr:`pts` for + inputs that store only DTS, such as AVI, whose generated PTS can go + backwards when the video has B-frames. + + :type: int | None + """ + if self.ptr.best_effort_timestamp == lib.AV_NOPTS_VALUE: + return None + return self.ptr.best_effort_timestamp + @property def duration(self): """ diff --git a/av/frame.pyi b/av/frame.pyi index 0d23a0fd8..a5860723b 100644 --- a/av/frame.pyi +++ b/av/frame.pyi @@ -9,6 +9,8 @@ from av.sidedata.sidedata import SideDataContainer class Frame: dts: int | None pts: int | None + @property + def best_effort_timestamp(self) -> int | None: ... duration: int @property def time_base(self) -> AVRational: ... diff --git a/include/avcodec.pxd b/include/avcodec.pxd index 5fc19435a..03556dd6e 100644 --- a/include/avcodec.pxd +++ b/include/avcodec.pxd @@ -373,6 +373,7 @@ cdef extern from "libavcodec/avcodec.h" nogil: int64_t pts int64_t pkt_dts + int64_t best_effort_timestamp void *opaque int sample_rate AVBufferRef *buf[8] diff --git a/tests/test_decode.py b/tests/test_decode.py index 27aa39486..091825244 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -108,6 +108,15 @@ def test_decoded_video_frame_count(self) -> None: assert frame_count == video_stream.frames + def test_best_effort_timestamp(self) -> None: + assert av.VideoFrame(16, 16, "yuv420p").best_effort_timestamp is None + + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + for frame in container.decode(video=0): + # MP4 stores valid PTS, so the decoder's estimate is the PTS. + assert frame.pts is not None + assert frame.best_effort_timestamp == frame.pts + def test_flushed_frames_keep_time_base(self) -> None: # `decode()` with no packet has no packet to take the time base from; # it must fall back to the one the container set on the context.