diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 19c5685e1..ae641c115 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,6 +36,7 @@ Features: - Add ``VideoFrame.solid()`` to create a frame filled with one color, in any non-paletted pixel format. - Add ``InputContainer.probe_score``, ``InputContainer.duration_estimation_method``, ``VideoStream.codec_rate``, ``VideoStream.video_delay``, ``Codec.wrapper_name``, and ``Codec.max_lowres``. - Add ``VideoFrame.crop_top``, ``crop_bottom``, ``crop_left``, ``crop_right``, and ``VideoFrame.apply_cropping()``. +- Add ``InputContainer.programs``, exposing FFmpeg programs such as MPEG-TS channels, with their streams, metadata, and a settable ``discard``. - Add ``Frame.decode_error_flags`` as a ``DecodeError`` flag and ``VideoFrame.repeat_pict``. - Add ``Stream.cctx`` as the name for a stream's codec context; ``Stream.codec_context`` remains as an alias. - Deprecate ``AudioStream.format``, ``AudioStream.layout``, ``VideoStream.format``, ``VideoStream.thread_count``, and ``VideoStream.thread_type``, which pass through to the codec context; use ``stream.cctx.format``, ``stream.cctx.layout``, ``stream.cctx.thread_count``, and ``stream.cctx.thread_type`` instead. Passing these to ``add_stream()`` is not deprecated. diff --git a/av/container/input.py b/av/container/input.py index 06178d71b..1219e6d36 100644 --- a/av/container/input.py +++ b/av/container/input.py @@ -1,5 +1,6 @@ import cython from cython.cimports.av.codec.context import CodecContext, wrap_codec_context +from cython.cimports.av.container.program import wrap_program from cython.cimports.av.container.streams import StreamContainer from cython.cimports.av.dictionary import Dictionary from cython.cimports.av.packet import Packet @@ -145,6 +146,17 @@ def size(self): if size >= 0: return size + @property + def programs(self): + """The programs in this container, such as the channels of an MPEG-TS + broadcast. Empty for formats without programs. + + :type: tuple[Program, ...] + """ + self._assert_open() + i: cython.uint + return tuple(wrap_program(self, i) for i in range(self.ptr.nb_programs)) + @property def probe_score(self): """How confident FFmpeg was when it detected the input format, from 0 to 100. diff --git a/av/container/input.pyi b/av/container/input.pyi index 9636efef4..ae598bcee 100644 --- a/av/container/input.pyi +++ b/av/container/input.pyi @@ -11,6 +11,7 @@ from av.video.frame import VideoFrame from av.video.stream import VideoStream from .core import Container +from .program import Program from .streams import _Indices, _StreamSelector @final @@ -21,6 +22,8 @@ class InputContainer(Container): bit_rate: int size: int | None probe_score: int + @property + def programs(self) -> tuple[Program, ...]: ... duration_estimation_method: Literal["pts", "stream", "bitrate"] | None @overload diff --git a/av/container/program.pxd b/av/container/program.pxd new file mode 100644 index 000000000..c053266d1 --- /dev/null +++ b/av/container/program.pxd @@ -0,0 +1,11 @@ +cimport libav as lib + +from av.container.core cimport Container + + +cdef class Program: + cdef Container container + cdef readonly int index + cdef lib.AVProgram *_ptr(self) + +cdef Program wrap_program(Container container, int index) diff --git a/av/container/program.py b/av/container/program.py new file mode 100644 index 000000000..dbfad5aab --- /dev/null +++ b/av/container/program.py @@ -0,0 +1,121 @@ +import cython +import cython.cimports.libav as lib +from cython.cimports.av.container.core import Container +from cython.cimports.av.utils import avdict_to_dict + +from av.stream import Discard + +_cinit_bypass_sentinel = cython.declare(object, object()) + + +@cython.cfunc +def wrap_program(container: Container, index: cython.int) -> Program: + obj: Program = Program(_cinit_bypass_sentinel) + obj.container = container + obj.index = index + return obj + + +@cython.final +@cython.cclass +class Program: + """A program in a container: a group of streams that play together. + + MPEG-TS carries several programs (TV channels, in broadcast) in one stream. + Other formats, such as HLS, use programs for their variants. + + Wraps :ffmpeg:`AVProgram`. Get one from :attr:`.InputContainer.programs`. + """ + + def __cinit__(self, sentinel): + if sentinel is not _cinit_bypass_sentinel: + raise RuntimeError("cannot manually instantiate Program") + + @cython.cfunc + def _ptr(self) -> cython.pointer[lib.AVProgram]: + self.container._assert_open() + return self.container.ptr.programs[self.index] + + def __repr__(self): + if self.container.ptr == cython.NULL: + return f"" + return ( + f"" + ) + + @property + def id(self): + """The format-specific ID of this program. In MPEG-TS, this is the + program number (service ID). + + :type: int + """ + return self._ptr().id + + @property + def program_num(self): + """The MPEG-TS program number, or 0 for other formats. + + :type: int + """ + return self._ptr().program_num + + @property + def pmt_pid(self): + """The PID of this program's Program Map Table in MPEG-TS. + + :type: int + """ + return self._ptr().pmt_pid + + @property + def pcr_pid(self): + """The PID carrying this program's clock reference in MPEG-TS. + + :type: int + """ + return self._ptr().pcr_pid + + @property + def metadata(self): + """Metadata for this program, such as ``service_name`` and + ``service_provider`` in MPEG-TS. + + :type: dict[str, str] + """ + return avdict_to_dict(self._ptr().metadata) + + @property + def streams(self): + """The streams that belong to this program. + + A stream can belong to more than one program. + + :type: tuple[Stream, ...] + """ + ptr: cython.pointer[lib.AVProgram] = self._ptr() + by_index = {stream.index: stream for stream in self.container.streams} + result = [] + i: cython.uint + for i in range(ptr.nb_stream_indexes): + stream = by_index.get(ptr.stream_index[i]) + if stream is not None: + result.append(stream) + return tuple(result) + + @property + def discard(self): + """Controls whether the demuxer reads this program's packets. + + Set to :attr:`.Discard.all` to skip a program entirely. In MPEG-TS, a + stream's packets are only dropped once every program it belongs to is + discarded. + + :type: Discard + """ + return Discard(self._ptr().discard) + + @discard.setter + def discard(self, value): + self._ptr().discard = Discard(value).value diff --git a/av/container/program.pyi b/av/container/program.pyi new file mode 100644 index 000000000..ab7f9d8fa --- /dev/null +++ b/av/container/program.pyi @@ -0,0 +1,24 @@ +from typing import final + +from av.stream import Discard, Stream + +@final +class Program: + @property + def index(self) -> int: ... + @property + def id(self) -> int: ... + @property + def program_num(self) -> int: ... + @property + def pmt_pid(self) -> int: ... + @property + def pcr_pid(self) -> int: ... + @property + def metadata(self) -> dict[str, str]: ... + @property + def streams(self) -> tuple[Stream, ...]: ... + @property + def discard(self) -> Discard: ... + @discard.setter + def discard(self, value: Discard | int) -> None: ... diff --git a/docs/api/container.rst b/docs/api/container.rst index 1a1fa0602..1fb5537f2 100644 --- a/docs/api/container.rst +++ b/docs/api/container.rst @@ -38,6 +38,17 @@ Input Containers :members: +Programs +-------- + +.. currentmodule:: av.container.program + +.. autoclass:: Program + :members: + +.. currentmodule:: av.container + + Output Containers ----------------- diff --git a/include/avformat.pxd b/include/avformat.pxd index aaea45138..cae7a5620 100644 --- a/include/avformat.pxd +++ b/include/avformat.pxd @@ -35,6 +35,16 @@ cdef extern from "libavformat/avformat.h" nogil: AVRational r_frame_rate AVRational sample_aspect_ratio + cdef struct AVProgram: + int id + AVDiscard discard + unsigned int *stream_index + unsigned int nb_stream_indexes + AVDictionary *metadata + int program_num + int pmt_pid + int pcr_pid + cdef struct AVChapter: int64_t id int64_t start @@ -146,6 +156,8 @@ cdef extern from "libavformat/avformat.h" nogil: const AVClass *av_class unsigned int nb_streams AVStream **streams + unsigned int nb_programs + AVProgram **programs unsigned int nb_chapters AVChapter **chapters const AVInputFormat *iformat diff --git a/tests/test_programs.py b/tests/test_programs.py new file mode 100644 index 000000000..c4c1eecdb --- /dev/null +++ b/tests/test_programs.py @@ -0,0 +1,66 @@ +from collections import Counter + +import pytest + +import av +from av.container.program import Program +from av.stream import Discard + +from .common import fate_suite + + +def test_programs() -> None: + with av.open(fate_suite("mpegts/loewe.ts")) as container: + programs = container.programs + assert len(programs) == 1 + + program = programs[0] + assert isinstance(program, Program) + assert program.index == 0 + assert program.id == 769 + assert program.program_num == 769 + assert program.pmt_pid == 1363 + assert program.pcr_pid == 1361 + assert program.metadata == { + "service_name": "Das Erste HD", + "service_provider": "LOEWE. DR+", + } + assert program.streams == (container.streams[0], container.streams[1]) + assert program.discard == Discard.none + assert repr(program).startswith(" None: + path = fate_suite("mpegts/loewe.ts") + with av.open(path) as container: + counts = Counter(p.stream_index for p in container.demux() if p.size) + assert set(counts) == {0, 1, 2} + + with av.open(path) as container: + program = container.programs[0] + program.discard = Discard.all + assert program.discard == Discard.all + counts = Counter(p.stream_index for p in container.demux() if p.size) + + # Stream 2 is not part of the program, so it is still read. + assert set(counts) == {2} + + +def test_programs_none() -> None: + with av.open(fate_suite("h264/interlaced_crop.mp4")) as container: + assert container.programs == () + + +def test_program_after_close() -> None: + container = av.open(fate_suite("mpegts/loewe.ts")) + program = container.programs[0] + container.close() + + assert "container closed" in repr(program) + with pytest.raises(AssertionError): + program.id + + +def test_program_cannot_be_instantiated() -> None: + with pytest.raises(RuntimeError): + Program(None) # type: ignore[call-arg]