From 761d9ec94e493181071e7fa0b676641758d9ef60 Mon Sep 17 00:00:00 2001 From: Max Christoph Date: Fri, 29 Aug 2025 16:29:27 +0200 Subject: [PATCH 1/5] Implemented binding to chapters (AVChapter) attribute in the Container class. Call container.chapters() to receive a list of all chapters. --- av/container/core.pyx | 18 ++++++++++++++++++ include/libavformat/avformat.pxd | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/av/container/core.pyx b/av/container/core.pyx index d8950c76f..7a7a7a776 100755 --- a/av/container/core.pyx +++ b/av/container/core.pyx @@ -1,3 +1,5 @@ +from fractions import Fraction + from cython.operator cimport dereference from libc.stdint cimport int64_t @@ -330,6 +332,22 @@ cdef class Container: self._assert_open() self.ptr.flags = value + def chapters(self): + self._assert_open() + cdef list result = [] + cdef int i + + for i in range(self.ptr.nb_chapters): + ch = self.ptr.chapters[i] + result.append({ + "id": ch.id, + "start": ch.start, + "end": ch.end, + "time_base": Fraction(ch.time_base.num, ch.time_base.den), + "metadata": avdict_to_dict(ch.metadata, self.metadata_encoding, self.metadata_errors), + }) + return result + def open( file, mode=None, diff --git a/include/libavformat/avformat.pxd b/include/libavformat/avformat.pxd index 09eede855..3816b46fa 100644 --- a/include/libavformat/avformat.pxd +++ b/include/libavformat/avformat.pxd @@ -44,6 +44,14 @@ cdef extern from "libavformat/avformat.h" nogil: AVRational r_frame_rate AVRational sample_aspect_ratio + cdef struct AVChapter: + int id + int64_t start + int64_t end + AVRational time_base + AVDictionary *metadata + + # http://ffmpeg.org/doxygen/trunk/structAVIOContext.html cdef struct AVIOContext: unsigned char* buffer @@ -173,6 +181,9 @@ cdef extern from "libavformat/avformat.h" nogil: unsigned int nb_streams AVStream **streams + unsigned int nb_chapters + AVChapter **chapters + AVInputFormat *iformat AVOutputFormat *oformat From 57fe38cd19ea609a0b0e7c8fdfd0898f6e4c5fb2 Mon Sep 17 00:00:00 2001 From: Max Christoph Date: Sat, 30 Aug 2025 17:43:46 +0200 Subject: [PATCH 2/5] Add test for chapter method --- tests/test_chapters.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_chapters.py diff --git a/tests/test_chapters.py b/tests/test_chapters.py new file mode 100644 index 000000000..0dc2d850b --- /dev/null +++ b/tests/test_chapters.py @@ -0,0 +1,16 @@ +from fractions import Fraction + +import av + +from .common import fate_suite + +def test_chapters() -> None: + expected = [ + {'id': 1, 'start': 0, 'end': 5000, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'start'}}, + {'id': 2, 'start': 5000, 'end': 10500, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'Five Seconds'}}, + {'id': 3, 'start': 10500, 'end': 15000, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'Ten point 5 seconds'}}, + {'id': 4, 'start': 15000, 'end': 19849, 'time_base': Fraction(1, 1000), 'metadata': {'title': '15 sec - over soon'}} + ] + path = fate_suite("vorbis/vorbis_chapter_extension_demo.ogg") + with av.open(path) as container: + assert container.chapters() == expected \ No newline at end of file From 4f2f55d4f762cc38b9a49150592e733c70bf65f4 Mon Sep 17 00:00:00 2001 From: Max Christoph Date: Mon, 1 Sep 2025 10:00:28 +0200 Subject: [PATCH 3/5] fix linter issues --- av/container/input.pyi | 1 + tests/test_chapters.py | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/av/container/input.pyi b/av/container/input.pyi index 90154c331..e35da3558 100644 --- a/av/container/input.pyi +++ b/av/container/input.pyi @@ -36,6 +36,7 @@ class InputContainer(Container): def decode( self, *args: Any, **kwargs: Any ) -> Iterator[VideoFrame | AudioFrame | SubtitleSet]: ... + def chapters(self): ... def seek( self, offset: int, diff --git a/tests/test_chapters.py b/tests/test_chapters.py index 0dc2d850b..6a3d371b2 100644 --- a/tests/test_chapters.py +++ b/tests/test_chapters.py @@ -4,13 +4,38 @@ from .common import fate_suite + def test_chapters() -> None: expected = [ - {'id': 1, 'start': 0, 'end': 5000, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'start'}}, - {'id': 2, 'start': 5000, 'end': 10500, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'Five Seconds'}}, - {'id': 3, 'start': 10500, 'end': 15000, 'time_base': Fraction(1, 1000), 'metadata': {'title': 'Ten point 5 seconds'}}, - {'id': 4, 'start': 15000, 'end': 19849, 'time_base': Fraction(1, 1000), 'metadata': {'title': '15 sec - over soon'}} - ] + { + "id": 1, + "start": 0, + "end": 5000, + "time_base": Fraction(1, 1000), + "metadata": {"title": "start"}, + }, + { + "id": 2, + "start": 5000, + "end": 10500, + "time_base": Fraction(1, 1000), + "metadata": {"title": "Five Seconds"}, + }, + { + "id": 3, + "start": 10500, + "end": 15000, + "time_base": Fraction(1, 1000), + "metadata": {"title": "Ten point 5 seconds"}, + }, + { + "id": 4, + "start": 15000, + "end": 19849, + "time_base": Fraction(1, 1000), + "metadata": {"title": "15 sec - over soon"}, + }, + ] path = fate_suite("vorbis/vorbis_chapter_extension_demo.ogg") with av.open(path) as container: - assert container.chapters() == expected \ No newline at end of file + assert container.chapters() == expected From 5f181fd7c6081a42eded674a5a8e3b0932e0951f Mon Sep 17 00:00:00 2001 From: Max Christoph Date: Mon, 1 Sep 2025 10:00:45 +0200 Subject: [PATCH 4/5] Use avrational_to_fraction method instead of Fraction --- av/container/core.pyx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/av/container/core.pyx b/av/container/core.pyx index 7a7a7a776..de7a07dda 100755 --- a/av/container/core.pyx +++ b/av/container/core.pyx @@ -1,5 +1,3 @@ -from fractions import Fraction - from cython.operator cimport dereference from libc.stdint cimport int64_t @@ -17,7 +15,7 @@ from av.container.output cimport OutputContainer from av.container.pyio cimport pyio_close_custom_gil, pyio_close_gil from av.error cimport err_check, stash_exception from av.format cimport build_container_format -from av.utils cimport avdict_to_dict +from av.utils cimport avdict_to_dict, avrational_to_fraction from av.dictionary import Dictionary from av.logging import Capture as LogCapture @@ -343,7 +341,7 @@ cdef class Container: "id": ch.id, "start": ch.start, "end": ch.end, - "time_base": Fraction(ch.time_base.num, ch.time_base.den), + "time_base": avrational_to_fraction(&ch.time_base), "metadata": avdict_to_dict(ch.metadata, self.metadata_encoding, self.metadata_errors), }) return result From a5a96ad14882577a4ff4dad3636fb1c36eb8b2a6 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Mon, 1 Sep 2025 11:19:50 -0400 Subject: [PATCH 5/5] Add type hint --- Makefile | 2 +- av/container/core.pyi | 11 +++++++++-- av/container/input.pyi | 1 - 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 135d091ad..4858add5b 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ fate-suite: rsync -vrltLW rsync://fate-suite.ffmpeg.org/fate-suite/ tests/assets/fate-suite/ lint: - $(PIP) install -U ruff isort pillow numpy mypy==1.16.1 pytest + $(PIP) install -U ruff isort pillow numpy mypy==1.17.1 pytest ruff format --check av examples tests setup.py isort --check-only --diff av examples tests mypy av tests diff --git a/av/container/core.pyi b/av/container/core.pyi index d61d07110..8cd2a9dc5 100644 --- a/av/container/core.pyi +++ b/av/container/core.pyi @@ -2,7 +2,7 @@ from enum import Flag, IntEnum from fractions import Fraction from pathlib import Path from types import TracebackType -from typing import Any, Callable, ClassVar, Literal, Type, cast, overload +from typing import Any, Callable, ClassVar, Literal, Type, TypedDict, cast, overload from av.codec.hwaccel import HWAccel from av.format import ContainerFormat @@ -67,6 +67,13 @@ class AudioCodec(IntEnum): pcm_u8 = cast(int, ...) pcm_vidc = cast(int, ...) +class _Chapter(TypedDict): + id: int + start: int + end: int + time_base: Fraction | None + metadata: dict[str, str] + class Container: writeable: bool name: str @@ -86,7 +93,6 @@ class Container: open_timeout: Real | None read_timeout: Real | None flags: int - def __enter__(self) -> Container: ... def __exit__( self, @@ -96,6 +102,7 @@ class Container: ) -> bool: ... def set_timeout(self, timeout: Real | None) -> None: ... def start_timeout(self) -> None: ... + def chapters(self) -> list[_Chapter]: ... @overload def open( diff --git a/av/container/input.pyi b/av/container/input.pyi index e35da3558..90154c331 100644 --- a/av/container/input.pyi +++ b/av/container/input.pyi @@ -36,7 +36,6 @@ class InputContainer(Container): def decode( self, *args: Any, **kwargs: Any ) -> Iterator[VideoFrame | AudioFrame | SubtitleSet]: ... - def chapters(self): ... def seek( self, offset: int,