From f8393c22ceacab35415e58f873897083061ba1e9 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Tue, 1 Jul 2025 16:47:21 -0400 Subject: [PATCH 01/15] Add avindex functions and supporting struct to lib Signed-off-by: Max Ehrlich --- include/libavformat/avformat.pxd | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/include/libavformat/avformat.pxd b/include/libavformat/avformat.pxd index 3a2218f06..5e49c54a6 100644 --- a/include/libavformat/avformat.pxd +++ b/include/libavformat/avformat.pxd @@ -350,3 +350,13 @@ cdef extern from "libavformat/avformat.h" nogil: # custom cdef set pyav_get_available_formats() + + cdef struct AVIndexEntry: + int64_t pos + int64_t timestamp + int flags + int size + int min_distance + + cdef AVIndexEntry *avformat_index_get_entry(AVStream *st, int idx) + cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) From 5df977e862e20e051970b3c0bfa9e3df5974f6cc Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Tue, 1 Jul 2025 16:47:48 -0400 Subject: [PATCH 02/15] Add python implementation of IndexEntry struct Signed-off-by: Max Ehrlich --- av/index_entry.pxd | 6 ++++++ av/index_entry.pyi | 6 ++++++ av/index_entry.pyx | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 av/index_entry.pxd create mode 100644 av/index_entry.pyi create mode 100644 av/index_entry.pyx diff --git a/av/index_entry.pxd b/av/index_entry.pxd new file mode 100644 index 000000000..77052f932 --- /dev/null +++ b/av/index_entry.pxd @@ -0,0 +1,6 @@ +cimport libav as lib + +cdef class IndexEntry: + + cdef lib.AVIndexEntry *ptr + cdef _init(self, lib.AVIndexEntry *ptr) \ No newline at end of file diff --git a/av/index_entry.pyi b/av/index_entry.pyi new file mode 100644 index 000000000..8d649d4c9 --- /dev/null +++ b/av/index_entry.pyi @@ -0,0 +1,6 @@ +class IndexEntry: + pos: int + timestamp: int + flags: int + size: int + min_distance: int diff --git a/av/index_entry.pyx b/av/index_entry.pyx new file mode 100644 index 000000000..ba5450b87 --- /dev/null +++ b/av/index_entry.pyx @@ -0,0 +1,36 @@ + +import cython +from cython.cimports import libav as lib + +cdef object _cinit_bypass_sentinel = object() + +cdef class IndexEntry: + def __cinit__(self, sentinel): + if sentinel is not _cinit_bypass_sentinel: + raise RuntimeError("cannot manually instatiate IndexEntry") + + cdef _init(self, lib.AVIndexEntry *ptr): + self.ptr = ptr + + def __repr__(self): + return f"" + + @property + def pos(self): + return self.ptr.pos + + @property + def timestamp(self): + return self.ptr.timestamp + + @property + def flags(self): + return self.ptr.flags + + @property + def size(self): + return self.ptr.size + + @property + def min_distance(self): + return self.ptr.min_distance \ No newline at end of file From 63b1f05867eb1e02152ab59f3d3b5aa9a2378718 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Wed, 2 Jul 2025 07:56:31 -0400 Subject: [PATCH 03/15] Refactor Signed-off-by: Max Ehrlich --- av/{index_entry.pxd => indexentry.pxd} | 0 av/{index_entry.pyi => indexentry.pyi} | 0 av/{index_entry.pyx => indexentry.pyx} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename av/{index_entry.pxd => indexentry.pxd} (100%) rename av/{index_entry.pyi => indexentry.pyi} (100%) rename av/{index_entry.pyx => indexentry.pyx} (100%) diff --git a/av/index_entry.pxd b/av/indexentry.pxd similarity index 100% rename from av/index_entry.pxd rename to av/indexentry.pxd diff --git a/av/index_entry.pyi b/av/indexentry.pyi similarity index 100% rename from av/index_entry.pyi rename to av/indexentry.pyi diff --git a/av/index_entry.pyx b/av/indexentry.pyx similarity index 100% rename from av/index_entry.pyx rename to av/indexentry.pyx From 7b8eecc15926a9526360c7fddf9f4a2ccd832182 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Wed, 14 Jan 2026 08:57:45 -0500 Subject: [PATCH 04/15] Fix indexentry construction Signed-off-by: Max Ehrlich --- av/indexentry.pxd | 5 ++++- av/indexentry.pyx | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/av/indexentry.pxd b/av/indexentry.pxd index 77052f932..8628ce21d 100644 --- a/av/indexentry.pxd +++ b/av/indexentry.pxd @@ -3,4 +3,7 @@ cimport libav as lib cdef class IndexEntry: cdef lib.AVIndexEntry *ptr - cdef _init(self, lib.AVIndexEntry *ptr) \ No newline at end of file + cdef _init(self, lib.AVIndexEntry *ptr) + + +cdef IndexEntry wrap_index_entry(lib.AVIndexEntry *ptr) \ No newline at end of file diff --git a/av/indexentry.pyx b/av/indexentry.pyx index ba5450b87..b50487846 100644 --- a/av/indexentry.pyx +++ b/av/indexentry.pyx @@ -1,13 +1,17 @@ -import cython from cython.cimports import libav as lib cdef object _cinit_bypass_sentinel = object() +cdef IndexEntry wrap_index_entry(lib.AVIndexEntry *ptr): + cdef IndexEntry obj = IndexEntry(_cinit_bypass_sentinel) + obj._init(ptr) + return obj + cdef class IndexEntry: def __cinit__(self, sentinel): if sentinel is not _cinit_bypass_sentinel: - raise RuntimeError("cannot manually instatiate IndexEntry") + raise RuntimeError("cannot manually instantiate IndexEntry") cdef _init(self, lib.AVIndexEntry *ptr): self.ptr = ptr From 8fbebc322dd2f749f16a304449e3d65c1d7f9244 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Wed, 14 Jan 2026 08:58:06 -0500 Subject: [PATCH 05/15] Expose ffmpeg function for count of index entries Signed-off-by: Max Ehrlich --- include/libavformat/avformat.pxd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/include/libavformat/avformat.pxd b/include/libavformat/avformat.pxd index 5e49c54a6..abc701310 100644 --- a/include/libavformat/avformat.pxd +++ b/include/libavformat/avformat.pxd @@ -359,4 +359,5 @@ cdef extern from "libavformat/avformat.h" nogil: int min_distance cdef AVIndexEntry *avformat_index_get_entry(AVStream *st, int idx) - cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) + cdef int avformat_index_get_entries_count(AVStream *st) + cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) \ No newline at end of file From 4c90b18af1c8b5660907bb5b6088c2bb8bd3255c Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Wed, 14 Jan 2026 08:58:17 -0500 Subject: [PATCH 06/15] Add a pythonic container for index entries Signed-off-by: Max Ehrlich --- av/frameindex.pxd | 9 +++++++ av/frameindex.pyi | 14 ++++++++++ av/frameindex.pyx | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 av/frameindex.pxd create mode 100644 av/frameindex.pyi create mode 100644 av/frameindex.pyx diff --git a/av/frameindex.pxd b/av/frameindex.pxd new file mode 100644 index 000000000..4f7c95a9d --- /dev/null +++ b/av/frameindex.pxd @@ -0,0 +1,9 @@ +cimport libav as lib + +cdef class FrameIndex: + + cdef lib.AVStream *stream_ptr + cdef _init(self, lib.AVStream *ptr) + + +cdef FrameIndex wrap_frame_index(lib.AVStream *ptr) \ No newline at end of file diff --git a/av/frameindex.pyi b/av/frameindex.pyi new file mode 100644 index 000000000..cebc47ac9 --- /dev/null +++ b/av/frameindex.pyi @@ -0,0 +1,14 @@ +from typing import Iterator, overload + +from av.indexentry import IndexEntry + +class FrameIndex: + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[IndexEntry]: ... + @overload + def __getitem__(self, index: int) -> IndexEntry | None: ... + @overload + def __getitem__(self, index: slice) -> list[IndexEntry | None]: ... + def search_timestamp( + self, timestamp, *, backward: bool = True, any_frame: bool = False + ) -> int: ... diff --git a/av/frameindex.pyx b/av/frameindex.pyx new file mode 100644 index 000000000..4fb1d2ad9 --- /dev/null +++ b/av/frameindex.pyx @@ -0,0 +1,65 @@ +from cython.cimports import libav as lib +from typing import Iterator +from libc.stdint cimport int64_t + +from av.indexentry cimport IndexEntry, wrap_index_entry + +cdef object _cinit_bypass_sentinel = object() + +cdef FrameIndex wrap_frame_index(lib.AVStream *ptr): + cdef FrameIndex obj = FrameIndex(_cinit_bypass_sentinel) + obj._init(ptr) + return obj + + +cdef class FrameIndex: + def __cinit__(self, name): + if name is _cinit_bypass_sentinel: + return + raise RuntimeError("cannot manually instantiate FrameIndex") + + cdef _init(self, lib.AVStream *ptr): + self.stream_ptr = ptr + + def __repr__(self): + return f"" + + def __len__(self) -> int: + with nogil: + return lib.avformat_index_get_entries_count(self.stream_ptr) + + def __iter__(self) -> Iterator[IndexEntry]: + for i in range(len(self)): + yield self[i] + + def __getitem__(self, index: int | slice) -> IndexEntry | list[IndexEntry | None] | None: + cdef int c_idx + if isinstance(index, int): + c_idx = index + with nogil: + entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) + + if entry == NULL: + return None + + return wrap_index_entry(entry) + elif isinstance(index, slice): + start, stop, step = index.indices(len(self)) + return [self[i] for i in range(start, stop, step)] + else: + raise TypeError("Index must be an integer or a slice") + + def search_timestamp(self, timestamp, *, bint backward=True, bint any_frame=False): + cdef int64_t c_timestamp = timestamp + cdef int flags = 0 + + if backward: + flags |= lib.AVSEEK_FLAG_BACKWARD + if any_frame: + flags |= lib.AVSEEK_FLAG_ANY + + with nogil: + idx = lib.av_index_search_timestamp(self.stream_ptr, c_timestamp, flags) + + return idx + From 1a063c19a1e4e976239e35b67b535e2dcd1c2be3 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Wed, 14 Jan 2026 08:58:27 -0500 Subject: [PATCH 07/15] Expose the index entries for each stream Signed-off-by: Max Ehrlich --- av/stream.pxd | 3 +++ av/stream.py | 2 ++ av/stream.pyi | 2 ++ 3 files changed, 7 insertions(+) diff --git a/av/stream.pxd b/av/stream.pxd index 9aa6616e5..b4e814452 100644 --- a/av/stream.pxd +++ b/av/stream.pxd @@ -4,6 +4,7 @@ from av.codec.context cimport CodecContext from av.container.core cimport Container from av.frame cimport Frame from av.packet cimport Packet +from av.frameindex cimport FrameIndex cdef class Stream: @@ -16,6 +17,8 @@ cdef class Stream: # CodecContext attributes. cdef readonly CodecContext codec_context + cdef readonly FrameIndex frame_index + # Private API. cdef _init(self, Container, lib.AVStream*, CodecContext) cdef _finalize_for_output(self) diff --git a/av/stream.py b/av/stream.py index a43c43496..6d99569dc 100644 --- a/av/stream.py +++ b/av/stream.py @@ -3,6 +3,7 @@ import cython from cython.cimports import libav as lib from cython.cimports.av.error import err_check +from cython.cimports.av.frameindex import wrap_frame_index from cython.cimports.av.packet import Packet from cython.cimports.av.utils import ( avdict_to_dict, @@ -106,6 +107,7 @@ def _init( ): self.container = container self.ptr = stream + self.frame_index = wrap_frame_index(self.ptr) self.codec_context = codec_context if self.codec_context: diff --git a/av/stream.pyi b/av/stream.pyi index f6ca196c8..c1c25f047 100644 --- a/av/stream.pyi +++ b/av/stream.pyi @@ -4,6 +4,7 @@ from typing import Literal, cast from .codec import Codec, CodecContext from .container import Container +from .frameindex import FrameIndex class Disposition(Flag): default = cast(int, ...) @@ -32,6 +33,7 @@ class Stream: codec: Codec codec_context: CodecContext metadata: dict[str, str] + frame_index: FrameIndex id: int profiles: list[str] profile: str | None From f4eaf7a73eddc7d04f247ba9667b9fbf09f99f4a Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 11:01:47 -0500 Subject: [PATCH 08/15] Add proper bounds checking Signed-off-by: Max Ehrlich --- av/frameindex.pyx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/av/frameindex.pyx b/av/frameindex.pyx index 4fb1d2ad9..e7d2ba075 100644 --- a/av/frameindex.pyx +++ b/av/frameindex.pyx @@ -35,6 +35,11 @@ cdef class FrameIndex: def __getitem__(self, index: int | slice) -> IndexEntry | list[IndexEntry | None] | None: cdef int c_idx if isinstance(index, int): + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError(f"Frame index {index} out of bounds for size {len(self)}") + c_idx = index with nogil: entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) From aac9cd78b7067a8c6c8fb1c06019e26b05069cdb Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 11:02:11 -0500 Subject: [PATCH 09/15] Add more pythonic interpretation of flags to match packet/frame/etc Signed-off-by: Max Ehrlich --- av/indexentry.pyi | 2 ++ av/indexentry.pyx | 10 +++++++++- include/libavformat/avformat.pxd | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/av/indexentry.pyi b/av/indexentry.pyi index 8d649d4c9..5365bc768 100644 --- a/av/indexentry.pyi +++ b/av/indexentry.pyi @@ -2,5 +2,7 @@ class IndexEntry: pos: int timestamp: int flags: int + is_keyframe: bool + is_discard: bool size: int min_distance: int diff --git a/av/indexentry.pyx b/av/indexentry.pyx index b50487846..3c0477ca7 100644 --- a/av/indexentry.pyx +++ b/av/indexentry.pyx @@ -30,6 +30,14 @@ cdef class IndexEntry: @property def flags(self): return self.ptr.flags + + @property + def is_keyframe(self): + return bool(self.ptr.flags & lib.AVINDEX_KEYFRAME) + + @property + def is_discard(self): + return bool(self.ptr.flags & lib.AVINDEX_DISCARD_FRAME) @property def size(self): @@ -37,4 +45,4 @@ cdef class IndexEntry: @property def min_distance(self): - return self.ptr.min_distance \ No newline at end of file + return self.ptr.min_distance diff --git a/include/libavformat/avformat.pxd b/include/libavformat/avformat.pxd index abc701310..1f7ba18f8 100644 --- a/include/libavformat/avformat.pxd +++ b/include/libavformat/avformat.pxd @@ -358,6 +358,10 @@ cdef extern from "libavformat/avformat.h" nogil: int size int min_distance + cdef enum: + AVINDEX_KEYFRAME + AVINDEX_DISCARD_FRAME + cdef AVIndexEntry *avformat_index_get_entry(AVStream *st, int idx) cdef int avformat_index_get_entries_count(AVStream *st) - cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) \ No newline at end of file + cdef int av_index_search_timestamp(AVStream *st, int64_t timestamp, int flags) From 6d6b64c91933fbae0667b2609c85e827bd0d9618 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 11:02:25 -0500 Subject: [PATCH 10/15] Add unit tests Signed-off-by: Max Ehrlich --- tests/test_frameindex.py | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_frameindex.py diff --git a/tests/test_frameindex.py b/tests/test_frameindex.py new file mode 100644 index 000000000..cbe67e508 --- /dev/null +++ b/tests/test_frameindex.py @@ -0,0 +1,86 @@ +import av + +from .common import TestCase, fate_suite + + +class TestFrameIndex(TestCase): + def test_frame_index_len_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + assert len(stream.frame_index) == stream.frames + + def test_frame_index_len_webm(self) -> None: + with av.open( + fate_suite("vp9-test-vectors/vp90-2-00-quantizer-00.webm") + ) as container: + stream = container.streams.video[0] + frame_index_len_before_demux = len(stream.frame_index) + + keyframes = len([p for p in container.demux(video=0) if p.is_keyframe]) + assert frame_index_len_before_demux == keyframes + + def test_frame_index_search_timestamp_options_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + fi = stream.frame_index + + target_ts = -1 + + assert fi.search_timestamp(target_ts) == 0 + assert fi.search_timestamp(target_ts, any_frame=True) == 1 + assert fi.search_timestamp(target_ts, backward=False) == 21 + assert fi.search_timestamp(target_ts, backward=False, any_frame=True) == 1 + + e0 = fi[0] + e1 = fi[1] + e21 = fi[21] + assert e0 is not None and e0.timestamp == -2 and e0.is_keyframe + assert e1 is not None and e1.timestamp == -1 and not e1.is_keyframe + assert e21 is not None and e21.timestamp == 19 and e21.is_keyframe + + def test_frame_index_matches_packet_mp4(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + for i, packet in enumerate(container.demux(video=0)): + if packet.dts is not None: + entry = stream.frame_index[i] + assert entry is not None + assert entry.timestamp == packet.dts + + def test_frame_index_in_bounds(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + first = stream.frame_index[0] + first_neg = stream.frame_index[-len(stream.frame_index)] + last = stream.frame_index[-1] + last_pos = stream.frame_index[len(stream.frame_index) - 1] + assert first is not None + assert first_neg is not None + assert last is not None + assert last_pos is not None + assert first.timestamp == first_neg.timestamp + assert last.timestamp == last_pos.timestamp + + def test_frame_index_out_of_bounds(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + with self.assertRaises(IndexError): + _ = stream.frame_index[len(stream.frame_index)] + + with self.assertRaises(IndexError): + _ = stream.frame_index[-len(stream.frame_index) - 1] + + def test_frame_index_slice(self) -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + stream = container.streams.video[0] + + individual_indices = [stream.frame_index[i] for i in range(1,5)] + slice_indices = stream.frame_index[1:5] + assert len(individual_indices) == len(slice_indices) == 4 + assert all(entry is not None for entry in individual_indices) + assert all(entry is not None for entry in slice_indices) + assert all([ + i.timestamp == j.timestamp + for i, j in zip(individual_indices, slice_indices) + if i is not None and j is not None + ]) From f6033dd6acf4e8203adea68137867ef7ac8a796a Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 12:10:27 -0500 Subject: [PATCH 11/15] rename Signed-off-by: Max Ehrlich --- av/{frameindex.pxd => indexentries.pxd} | 4 +- av/{frameindex.pyi => indexentries.pyi} | 2 +- av/{frameindex.pyx => indexentries.pyx} | 10 ++--- av/stream.pxd | 4 +- av/stream.py | 4 +- av/stream.pyi | 4 +- ...est_frameindex.py => test_indexentries.py} | 42 +++++++++---------- 7 files changed, 35 insertions(+), 35 deletions(-) rename av/{frameindex.pxd => indexentries.pxd} (55%) rename av/{frameindex.pyi => indexentries.pyi} (95%) rename av/{frameindex.pyx => indexentries.pyx} (87%) rename tests/{test_frameindex.py => test_indexentries.py} (68%) diff --git a/av/frameindex.pxd b/av/indexentries.pxd similarity index 55% rename from av/frameindex.pxd rename to av/indexentries.pxd index 4f7c95a9d..0ffb15af7 100644 --- a/av/frameindex.pxd +++ b/av/indexentries.pxd @@ -1,9 +1,9 @@ cimport libav as lib -cdef class FrameIndex: +cdef class IndexEntries: cdef lib.AVStream *stream_ptr cdef _init(self, lib.AVStream *ptr) -cdef FrameIndex wrap_frame_index(lib.AVStream *ptr) \ No newline at end of file +cdef IndexEntries wrap_index_entries(lib.AVStream *ptr) \ No newline at end of file diff --git a/av/frameindex.pyi b/av/indexentries.pyi similarity index 95% rename from av/frameindex.pyi rename to av/indexentries.pyi index cebc47ac9..9cba0f9b3 100644 --- a/av/frameindex.pyi +++ b/av/indexentries.pyi @@ -2,7 +2,7 @@ from typing import Iterator, overload from av.indexentry import IndexEntry -class FrameIndex: +class IndexEntries: def __len__(self) -> int: ... def __iter__(self) -> Iterator[IndexEntry]: ... @overload diff --git a/av/frameindex.pyx b/av/indexentries.pyx similarity index 87% rename from av/frameindex.pyx rename to av/indexentries.pyx index e7d2ba075..821ceebd3 100644 --- a/av/frameindex.pyx +++ b/av/indexentries.pyx @@ -6,23 +6,23 @@ from av.indexentry cimport IndexEntry, wrap_index_entry cdef object _cinit_bypass_sentinel = object() -cdef FrameIndex wrap_frame_index(lib.AVStream *ptr): - cdef FrameIndex obj = FrameIndex(_cinit_bypass_sentinel) +cdef IndexEntries wrap_index_entries(lib.AVStream *ptr): + cdef IndexEntries obj = IndexEntries(_cinit_bypass_sentinel) obj._init(ptr) return obj -cdef class FrameIndex: +cdef class IndexEntries: def __cinit__(self, name): if name is _cinit_bypass_sentinel: return - raise RuntimeError("cannot manually instantiate FrameIndex") + raise RuntimeError("cannot manually instantiate IndexEntries") cdef _init(self, lib.AVStream *ptr): self.stream_ptr = ptr def __repr__(self): - return f"" + return f"" def __len__(self) -> int: with nogil: diff --git a/av/stream.pxd b/av/stream.pxd index b4e814452..2a7eec3c6 100644 --- a/av/stream.pxd +++ b/av/stream.pxd @@ -4,7 +4,7 @@ from av.codec.context cimport CodecContext from av.container.core cimport Container from av.frame cimport Frame from av.packet cimport Packet -from av.frameindex cimport FrameIndex +from av.indexentries cimport IndexEntries cdef class Stream: @@ -17,7 +17,7 @@ cdef class Stream: # CodecContext attributes. cdef readonly CodecContext codec_context - cdef readonly FrameIndex frame_index + cdef readonly IndexEntries index_entries # Private API. cdef _init(self, Container, lib.AVStream*, CodecContext) diff --git a/av/stream.py b/av/stream.py index 6d99569dc..868def2ab 100644 --- a/av/stream.py +++ b/av/stream.py @@ -3,7 +3,7 @@ import cython from cython.cimports import libav as lib from cython.cimports.av.error import err_check -from cython.cimports.av.frameindex import wrap_frame_index +from cython.cimports.av.indexentries import wrap_index_entries from cython.cimports.av.packet import Packet from cython.cimports.av.utils import ( avdict_to_dict, @@ -107,7 +107,7 @@ def _init( ): self.container = container self.ptr = stream - self.frame_index = wrap_frame_index(self.ptr) + self.index_entries = wrap_index_entries(self.ptr) self.codec_context = codec_context if self.codec_context: diff --git a/av/stream.pyi b/av/stream.pyi index c1c25f047..8790d2601 100644 --- a/av/stream.pyi +++ b/av/stream.pyi @@ -4,7 +4,7 @@ from typing import Literal, cast from .codec import Codec, CodecContext from .container import Container -from .frameindex import FrameIndex +from .indexentries import IndexEntries class Disposition(Flag): default = cast(int, ...) @@ -33,7 +33,7 @@ class Stream: codec: Codec codec_context: CodecContext metadata: dict[str, str] - frame_index: FrameIndex + index_entries: IndexEntries id: int profiles: list[str] profile: str | None diff --git a/tests/test_frameindex.py b/tests/test_indexentries.py similarity index 68% rename from tests/test_frameindex.py rename to tests/test_indexentries.py index cbe67e508..053ecd369 100644 --- a/tests/test_frameindex.py +++ b/tests/test_indexentries.py @@ -3,26 +3,26 @@ from .common import TestCase, fate_suite -class TestFrameIndex(TestCase): - def test_frame_index_len_mp4(self) -> None: +class TestIndexEntries(TestCase): + def test_index_entries_len_mp4(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] - assert len(stream.frame_index) == stream.frames + assert len(stream.index_entries) == stream.frames - def test_frame_index_len_webm(self) -> None: + def test_index_entries_len_webm(self) -> None: with av.open( fate_suite("vp9-test-vectors/vp90-2-00-quantizer-00.webm") ) as container: stream = container.streams.video[0] - frame_index_len_before_demux = len(stream.frame_index) + index_entries_len_before_demux = len(stream.index_entries) keyframes = len([p for p in container.demux(video=0) if p.is_keyframe]) - assert frame_index_len_before_demux == keyframes + assert index_entries_len_before_demux == keyframes - def test_frame_index_search_timestamp_options_mp4(self) -> None: + def test_index_entries_search_timestamp_options_mp4(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] - fi = stream.frame_index + fi = stream.index_entries target_ts = -1 @@ -38,22 +38,22 @@ def test_frame_index_search_timestamp_options_mp4(self) -> None: assert e1 is not None and e1.timestamp == -1 and not e1.is_keyframe assert e21 is not None and e21.timestamp == 19 and e21.is_keyframe - def test_frame_index_matches_packet_mp4(self) -> None: + def test_index_entries_matches_packet_mp4(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] for i, packet in enumerate(container.demux(video=0)): if packet.dts is not None: - entry = stream.frame_index[i] + entry = stream.index_entries[i] assert entry is not None assert entry.timestamp == packet.dts - def test_frame_index_in_bounds(self) -> None: + def test_index_entries_in_bounds(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] - first = stream.frame_index[0] - first_neg = stream.frame_index[-len(stream.frame_index)] - last = stream.frame_index[-1] - last_pos = stream.frame_index[len(stream.frame_index) - 1] + first = stream.index_entries[0] + first_neg = stream.index_entries[-len(stream.index_entries)] + last = stream.index_entries[-1] + last_pos = stream.index_entries[len(stream.index_entries) - 1] assert first is not None assert first_neg is not None assert last is not None @@ -61,21 +61,21 @@ def test_frame_index_in_bounds(self) -> None: assert first.timestamp == first_neg.timestamp assert last.timestamp == last_pos.timestamp - def test_frame_index_out_of_bounds(self) -> None: + def test_index_entries_out_of_bounds(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] with self.assertRaises(IndexError): - _ = stream.frame_index[len(stream.frame_index)] + _ = stream.index_entries[len(stream.index_entries)] with self.assertRaises(IndexError): - _ = stream.frame_index[-len(stream.frame_index) - 1] + _ = stream.index_entries[-len(stream.index_entries) - 1] - def test_frame_index_slice(self) -> None: + def test_index_entries_slice(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] - individual_indices = [stream.frame_index[i] for i in range(1,5)] - slice_indices = stream.frame_index[1:5] + individual_indices = [stream.index_entries[i] for i in range(1,5)] + slice_indices = stream.index_entries[1:5] assert len(individual_indices) == len(slice_indices) == 4 assert all(entry is not None for entry in individual_indices) assert all(entry is not None for entry in slice_indices) From 9e6ccb65b2c707325a1ae737f97fa829ff004f2c Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 12:18:52 -0500 Subject: [PATCH 12/15] clean up types Signed-off-by: Max Ehrlich --- av/indexentries.pyi | 4 ++-- av/indexentries.pyx | 5 ++--- tests/test_indexentries.py | 22 +++++----------------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/av/indexentries.pyi b/av/indexentries.pyi index 9cba0f9b3..c5d495711 100644 --- a/av/indexentries.pyi +++ b/av/indexentries.pyi @@ -6,9 +6,9 @@ class IndexEntries: def __len__(self) -> int: ... def __iter__(self) -> Iterator[IndexEntry]: ... @overload - def __getitem__(self, index: int) -> IndexEntry | None: ... + def __getitem__(self, index: int) -> IndexEntry: ... @overload - def __getitem__(self, index: slice) -> list[IndexEntry | None]: ... + def __getitem__(self, index: slice) -> list[IndexEntry]: ... def search_timestamp( self, timestamp, *, backward: bool = True, any_frame: bool = False ) -> int: ... diff --git a/av/indexentries.pyx b/av/indexentries.pyx index 821ceebd3..a9da0c37f 100644 --- a/av/indexentries.pyx +++ b/av/indexentries.pyx @@ -32,7 +32,7 @@ cdef class IndexEntries: for i in range(len(self)): yield self[i] - def __getitem__(self, index: int | slice) -> IndexEntry | list[IndexEntry | None] | None: + def __getitem__(self, index: int | slice) -> IndexEntry | list[IndexEntry]: cdef int c_idx if isinstance(index, int): if index < 0: @@ -45,7 +45,7 @@ cdef class IndexEntries: entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) if entry == NULL: - return None + raise IndexError(f"Index entry for {index} not found") return wrap_index_entry(entry) elif isinstance(index, slice): @@ -67,4 +67,3 @@ cdef class IndexEntries: idx = lib.av_index_search_timestamp(self.stream_ptr, c_timestamp, flags) return idx - diff --git a/tests/test_indexentries.py b/tests/test_indexentries.py index 053ecd369..a32666f59 100644 --- a/tests/test_indexentries.py +++ b/tests/test_indexentries.py @@ -34,18 +34,16 @@ def test_index_entries_search_timestamp_options_mp4(self) -> None: e0 = fi[0] e1 = fi[1] e21 = fi[21] - assert e0 is not None and e0.timestamp == -2 and e0.is_keyframe - assert e1 is not None and e1.timestamp == -1 and not e1.is_keyframe - assert e21 is not None and e21.timestamp == 19 and e21.is_keyframe + assert e0.timestamp == -2 and e0.is_keyframe + assert e1.timestamp == -1 and not e1.is_keyframe + assert e21.timestamp == 19 and e21.is_keyframe def test_index_entries_matches_packet_mp4(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] for i, packet in enumerate(container.demux(video=0)): if packet.dts is not None: - entry = stream.index_entries[i] - assert entry is not None - assert entry.timestamp == packet.dts + assert stream.index_entries[i].timestamp == packet.dts def test_index_entries_in_bounds(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: @@ -54,10 +52,6 @@ def test_index_entries_in_bounds(self) -> None: first_neg = stream.index_entries[-len(stream.index_entries)] last = stream.index_entries[-1] last_pos = stream.index_entries[len(stream.index_entries) - 1] - assert first is not None - assert first_neg is not None - assert last is not None - assert last_pos is not None assert first.timestamp == first_neg.timestamp assert last.timestamp == last_pos.timestamp @@ -77,10 +71,4 @@ def test_index_entries_slice(self) -> None: individual_indices = [stream.index_entries[i] for i in range(1,5)] slice_indices = stream.index_entries[1:5] assert len(individual_indices) == len(slice_indices) == 4 - assert all(entry is not None for entry in individual_indices) - assert all(entry is not None for entry in slice_indices) - assert all([ - i.timestamp == j.timestamp - for i, j in zip(individual_indices, slice_indices) - if i is not None and j is not None - ]) + assert all([i.timestamp == j.timestamp for i, j in zip(individual_indices, slice_indices)]) From 6c59e6999a627ab6daf3d729320c9d2b407599b3 Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Fri, 23 Jan 2026 13:25:51 -0500 Subject: [PATCH 13/15] make pure Signed-off-by: Max Ehrlich --- av/indexentries.pxd | 4 +- av/indexentries.py | 76 ++++++++++++++++++++++++++++ av/indexentries.pyx | 69 ------------------------- av/indexentry.pxd | 4 +- av/{indexentry.pyx => indexentry.py} | 33 +++++++----- 5 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 av/indexentries.py delete mode 100644 av/indexentries.pyx rename av/{indexentry.pyx => indexentry.py} (54%) diff --git a/av/indexentries.pxd b/av/indexentries.pxd index 0ffb15af7..44575476a 100644 --- a/av/indexentries.pxd +++ b/av/indexentries.pxd @@ -1,9 +1,9 @@ cimport libav as lib -cdef class IndexEntries: +cdef class IndexEntries: cdef lib.AVStream *stream_ptr cdef _init(self, lib.AVStream *ptr) -cdef IndexEntries wrap_index_entries(lib.AVStream *ptr) \ No newline at end of file +cdef IndexEntries wrap_index_entries(lib.AVStream *ptr) diff --git a/av/indexentries.py b/av/indexentries.py new file mode 100644 index 000000000..facd6fd71 --- /dev/null +++ b/av/indexentries.py @@ -0,0 +1,76 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.libc.stdint import int64_t + +from cython.cimports.av.indexentry import wrap_index_entry + + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_index_entries(ptr: cython.pointer[lib.AVStream]) -> IndexEntries: + obj: IndexEntries = IndexEntries(_cinit_bypass_sentinel) + obj._init(ptr) + return obj + + +@cython.cclass +class IndexEntries: + def __cinit__(self, sentinel): + if sentinel is _cinit_bypass_sentinel: + return + raise RuntimeError("cannot manually instantiate IndexEntries") + + @cython.cfunc + def _init(self, ptr: cython.pointer[lib.AVStream]): + self.stream_ptr = ptr + + def __repr__(self): + return f"" + + def __len__(self) -> int: + with cython.nogil: + return lib.avformat_index_get_entries_count(self.stream_ptr) + + def __iter__(self): + for i in range(len(self)): + yield self[i] + + def __getitem__(self, index): + if isinstance(index, int): + n = len(self) + if index < 0: + index += n + if index < 0 or index >= n: + raise IndexError(f"Index entries {index} out of bounds for size {n}") + + c_idx = cython.declare(cython.int, index) + with cython.nogil: + entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) + + if entry == cython.NULL: + raise IndexError("index entry not found") + + return wrap_index_entry(entry) + + elif isinstance(index, slice): + start, stop, step = index.indices(len(self)) + return [self[i] for i in range(start, stop, step)] + + else: + raise TypeError("Index must be an integer or a slice") + + def search_timestamp(self, timestamp, *, backward: bool = True, any_frame: bool = False): + c_timestamp = cython.declare(int64_t, timestamp) + flags = cython.declare(cython.int, 0) + + if backward: + flags |= lib.AVSEEK_FLAG_BACKWARD + if any_frame: + flags |= lib.AVSEEK_FLAG_ANY + + with cython.nogil: + idx = lib.av_index_search_timestamp(self.stream_ptr, c_timestamp, flags) + + return idx diff --git a/av/indexentries.pyx b/av/indexentries.pyx deleted file mode 100644 index a9da0c37f..000000000 --- a/av/indexentries.pyx +++ /dev/null @@ -1,69 +0,0 @@ -from cython.cimports import libav as lib -from typing import Iterator -from libc.stdint cimport int64_t - -from av.indexentry cimport IndexEntry, wrap_index_entry - -cdef object _cinit_bypass_sentinel = object() - -cdef IndexEntries wrap_index_entries(lib.AVStream *ptr): - cdef IndexEntries obj = IndexEntries(_cinit_bypass_sentinel) - obj._init(ptr) - return obj - - -cdef class IndexEntries: - def __cinit__(self, name): - if name is _cinit_bypass_sentinel: - return - raise RuntimeError("cannot manually instantiate IndexEntries") - - cdef _init(self, lib.AVStream *ptr): - self.stream_ptr = ptr - - def __repr__(self): - return f"" - - def __len__(self) -> int: - with nogil: - return lib.avformat_index_get_entries_count(self.stream_ptr) - - def __iter__(self) -> Iterator[IndexEntry]: - for i in range(len(self)): - yield self[i] - - def __getitem__(self, index: int | slice) -> IndexEntry | list[IndexEntry]: - cdef int c_idx - if isinstance(index, int): - if index < 0: - index += len(self) - if index < 0 or index >= len(self): - raise IndexError(f"Frame index {index} out of bounds for size {len(self)}") - - c_idx = index - with nogil: - entry = lib.avformat_index_get_entry(self.stream_ptr, c_idx) - - if entry == NULL: - raise IndexError(f"Index entry for {index} not found") - - return wrap_index_entry(entry) - elif isinstance(index, slice): - start, stop, step = index.indices(len(self)) - return [self[i] for i in range(start, stop, step)] - else: - raise TypeError("Index must be an integer or a slice") - - def search_timestamp(self, timestamp, *, bint backward=True, bint any_frame=False): - cdef int64_t c_timestamp = timestamp - cdef int flags = 0 - - if backward: - flags |= lib.AVSEEK_FLAG_BACKWARD - if any_frame: - flags |= lib.AVSEEK_FLAG_ANY - - with nogil: - idx = lib.av_index_search_timestamp(self.stream_ptr, c_timestamp, flags) - - return idx diff --git a/av/indexentry.pxd b/av/indexentry.pxd index 8628ce21d..55bac3ff4 100644 --- a/av/indexentry.pxd +++ b/av/indexentry.pxd @@ -1,9 +1,9 @@ cimport libav as lib -cdef class IndexEntry: +cdef class IndexEntry: cdef lib.AVIndexEntry *ptr cdef _init(self, lib.AVIndexEntry *ptr) -cdef IndexEntry wrap_index_entry(lib.AVIndexEntry *ptr) \ No newline at end of file +cdef IndexEntry wrap_index_entry(lib.AVIndexEntry *ptr) diff --git a/av/indexentry.pyx b/av/indexentry.py similarity index 54% rename from av/indexentry.pyx rename to av/indexentry.py index 3c0477ca7..8ca1cde14 100644 --- a/av/indexentry.pyx +++ b/av/indexentry.py @@ -1,23 +1,32 @@ +import cython +import cython.cimports.libav as lib -from cython.cimports import libav as lib -cdef object _cinit_bypass_sentinel = object() +_cinit_bypass_sentinel = cython.declare(object, object()) -cdef IndexEntry wrap_index_entry(lib.AVIndexEntry *ptr): - cdef IndexEntry obj = IndexEntry(_cinit_bypass_sentinel) + +@cython.cfunc +def wrap_index_entry(ptr: cython.pointer[lib.AVIndexEntry]) -> IndexEntry: + obj: IndexEntry = IndexEntry(_cinit_bypass_sentinel) obj._init(ptr) return obj -cdef class IndexEntry: + +@cython.cclass +class IndexEntry: def __cinit__(self, sentinel): if sentinel is not _cinit_bypass_sentinel: raise RuntimeError("cannot manually instantiate IndexEntry") - cdef _init(self, lib.AVIndexEntry *ptr): + @cython.cfunc + def _init(self, ptr: cython.pointer[lib.AVIndexEntry]): self.ptr = ptr def __repr__(self): - return f"" + return ( + f"" + ) @property def pos(self): @@ -25,8 +34,8 @@ def pos(self): @property def timestamp(self): - return self.ptr.timestamp - + return self.ptr.timestamp + @property def flags(self): return self.ptr.flags @@ -38,11 +47,11 @@ def is_keyframe(self): @property def is_discard(self): return bool(self.ptr.flags & lib.AVINDEX_DISCARD_FRAME) - + @property def size(self): return self.ptr.size - + @property - def min_distance(self): + def min_distance(self): return self.ptr.min_distance From 03d394ce8ab3b7087e64680c02912834130bc3ee Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Tue, 27 Jan 2026 13:16:51 -0500 Subject: [PATCH 14/15] Add some documentation Signed-off-by: Max Ehrlich --- av/indexentries.py | 14 ++++++++++++++ av/indexentry.py | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/av/indexentries.py b/av/indexentries.py index facd6fd71..0b90f480b 100644 --- a/av/indexentries.py +++ b/av/indexentries.py @@ -17,6 +17,14 @@ def wrap_index_entries(ptr: cython.pointer[lib.AVStream]) -> IndexEntries: @cython.cclass class IndexEntries: + """A sequence-like view of FFmpeg's per-stream index entries. + + Exposed as :attr:`~av.stream.Stream.index_entries`. + + The index is provided by the demuxer and may be empty or incomplete depending + on the container format. This is useful for fast multi-seek loops (e.g., decoding + at a lower-than-native framerate). + """ def __cinit__(self, sentinel): if sentinel is _cinit_bypass_sentinel: return @@ -62,6 +70,12 @@ def __getitem__(self, index): raise TypeError("Index must be an integer or a slice") def search_timestamp(self, timestamp, *, backward: bool = True, any_frame: bool = False): + """Search the underlying index for ``timestamp``. + + This wraps FFmpeg's ``av_index_search_timestamp``. + + Returns an index into this object, or ``-1`` if no match is found. + """ c_timestamp = cython.declare(int64_t, timestamp) flags = cython.declare(cython.int, 0) diff --git a/av/indexentry.py b/av/indexentry.py index 8ca1cde14..7e80d9fa5 100644 --- a/av/indexentry.py +++ b/av/indexentry.py @@ -14,6 +14,12 @@ def wrap_index_entry(ptr: cython.pointer[lib.AVIndexEntry]) -> IndexEntry: @cython.cclass class IndexEntry: + """A single entry from a stream's index. + + This is a thin wrapper around FFmpeg's ``AVIndexEntry``. + + The exact meaning of the fields depends on the container/demuxer. + """ def __cinit__(self, sentinel): if sentinel is not _cinit_bypass_sentinel: raise RuntimeError("cannot manually instantiate IndexEntry") From 652c17fd3893cc11df7712bffd33ffffbbd4094c Mon Sep 17 00:00:00 2001 From: Max Ehrlich Date: Tue, 27 Jan 2026 13:30:40 -0500 Subject: [PATCH 15/15] Fix formatting Signed-off-by: Max Ehrlich --- av/indexentries.py | 11 ++++++----- av/indexentry.py | 2 +- av/stream.pxd | 2 +- tests/test_indexentries.py | 9 +++++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/av/indexentries.py b/av/indexentries.py index 0b90f480b..ecbc83d27 100644 --- a/av/indexentries.py +++ b/av/indexentries.py @@ -1,9 +1,7 @@ import cython import cython.cimports.libav as lib -from cython.cimports.libc.stdint import int64_t - from cython.cimports.av.indexentry import wrap_index_entry - +from cython.cimports.libc.stdint import int64_t _cinit_bypass_sentinel = cython.declare(object, object()) @@ -22,9 +20,10 @@ class IndexEntries: Exposed as :attr:`~av.stream.Stream.index_entries`. The index is provided by the demuxer and may be empty or incomplete depending - on the container format. This is useful for fast multi-seek loops (e.g., decoding + on the container format. This is useful for fast multi-seek loops (e.g., decoding at a lower-than-native framerate). """ + def __cinit__(self, sentinel): if sentinel is _cinit_bypass_sentinel: return @@ -69,7 +68,9 @@ def __getitem__(self, index): else: raise TypeError("Index must be an integer or a slice") - def search_timestamp(self, timestamp, *, backward: bool = True, any_frame: bool = False): + def search_timestamp( + self, timestamp, *, backward: bool = True, any_frame: bool = False + ): """Search the underlying index for ``timestamp``. This wraps FFmpeg's ``av_index_search_timestamp``. diff --git a/av/indexentry.py b/av/indexentry.py index 7e80d9fa5..4d793f0cd 100644 --- a/av/indexentry.py +++ b/av/indexentry.py @@ -1,7 +1,6 @@ import cython import cython.cimports.libav as lib - _cinit_bypass_sentinel = cython.declare(object, object()) @@ -20,6 +19,7 @@ class IndexEntry: The exact meaning of the fields depends on the container/demuxer. """ + def __cinit__(self, sentinel): if sentinel is not _cinit_bypass_sentinel: raise RuntimeError("cannot manually instantiate IndexEntry") diff --git a/av/stream.pxd b/av/stream.pxd index 2a7eec3c6..ecdbe8029 100644 --- a/av/stream.pxd +++ b/av/stream.pxd @@ -3,8 +3,8 @@ cimport libav as lib from av.codec.context cimport CodecContext from av.container.core cimport Container from av.frame cimport Frame -from av.packet cimport Packet from av.indexentries cimport IndexEntries +from av.packet cimport Packet cdef class Stream: diff --git a/tests/test_indexentries.py b/tests/test_indexentries.py index a32666f59..c0d5cd393 100644 --- a/tests/test_indexentries.py +++ b/tests/test_indexentries.py @@ -68,7 +68,12 @@ def test_index_entries_slice(self) -> None: with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: stream = container.streams.video[0] - individual_indices = [stream.index_entries[i] for i in range(1,5)] + individual_indices = [stream.index_entries[i] for i in range(1, 5)] slice_indices = stream.index_entries[1:5] assert len(individual_indices) == len(slice_indices) == 4 - assert all([i.timestamp == j.timestamp for i, j in zip(individual_indices, slice_indices)]) + assert all( + [ + i.timestamp == j.timestamp + for i, j in zip(individual_indices, slice_indices) + ] + )