diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 80513c758..19c5685e1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -34,6 +34,9 @@ v19.1.0 (Unreleased) 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 ``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/codec/codec.py b/av/codec/codec.py index d2ca14911..adb11255f 100644 --- a/av/codec/codec.py +++ b/av/codec/codec.py @@ -178,6 +178,35 @@ def canonical_name(self): def long_name(self): return self.ptr.long_name or "" + @property + def wrapper_name(self): + """ + The name of the external library or hardware API this codec wraps, or + ``None`` for codecs native to FFmpeg. + + E.g: ``'at'`` for ``aac_at``, which wraps AudioToolbox. Not every + wrapper sets this; FFmpeg 9.0 added it to more of them, such as + ``h264_videotoolbox`` and ``libx264``. + + Wraps :ffmpeg:`AVCodec.wrapper_name`. + + :rtype: str | None + """ + if self.ptr.wrapper_name != cython.NULL: + return self.ptr.wrapper_name + + @property + def max_lowres(self): + """ + The highest ``lowres`` option value this decoder supports, or ``0`` if it + cannot decode at reduced resolution. + + Wraps :ffmpeg:`AVCodec.max_lowres`. + + :rtype: int + """ + return self.ptr.max_lowres + @property def type(self): """ diff --git a/av/codec/codec.pyi b/av/codec/codec.pyi index 830abbee2..3af942bb6 100644 --- a/av/codec/codec.pyi +++ b/av/codec/codec.pyi @@ -72,6 +72,10 @@ class Codec: @property def long_name(self) -> str: ... @property + def wrapper_name(self) -> str | None: ... + @property + def max_lowres(self) -> int: ... + @property def type( self, ) -> Literal["video", "audio", "data", "subtitle", "attachment", "unknown"]: ... diff --git a/av/container/input.py b/av/container/input.py index d654095a3..06178d71b 100644 --- a/av/container/input.py +++ b/av/container/input.py @@ -145,6 +145,43 @@ def size(self): if size >= 0: return size + @property + def probe_score(self): + """How confident FFmpeg was when it detected the input format, from 0 to 100. + + Low scores mean the format was a guess. It is 0 when ``format=`` was + given, since no probing happens. + + Wraps :ffmpeg:`AVFormatContext.probe_score`. + + :type: int + """ + self._assert_open() + return self.ptr.probe_score + + @property + def duration_estimation_method(self): + """How FFmpeg worked out :attr:`duration`, or ``None`` if it is unknown. + + - ``"pts"``: from the timestamps of the packets, which is accurate. + - ``"stream"``: from a stream with a known duration. + - ``"bitrate"``: from the file size and bitrate, which can be far off, + especially for variable bitrate files. + + Wraps :ffmpeg:`AVFormatContext.duration_estimation_method`. + + :type: Literal["pts", "stream", "bitrate"] | None + """ + self._assert_open() + if self.ptr.duration == lib.AV_NOPTS_VALUE: + return None + method = self.ptr.duration_estimation_method + if method == lib.AVFMT_DURATION_FROM_PTS: + return "pts" + if method == lib.AVFMT_DURATION_FROM_STREAM: + return "stream" + return "bitrate" + def close(self): close_input(self) diff --git a/av/container/input.pyi b/av/container/input.pyi index e191e03c0..9636efef4 100644 --- a/av/container/input.pyi +++ b/av/container/input.pyi @@ -1,5 +1,5 @@ from collections.abc import Iterator -from typing import final, overload +from typing import Literal, final, overload from av.audio.frame import AudioFrame from av.audio.stream import AudioStream @@ -20,6 +20,8 @@ class InputContainer(Container): duration: int | None bit_rate: int size: int | None + probe_score: int + duration_estimation_method: Literal["pts", "stream", "bitrate"] | None @overload def demux(self, video_stream: VideoStream) -> Iterator[Packet[VideoStream]]: ... diff --git a/av/frame.py b/av/frame.py index 9c78ea73c..b9dc2d6a4 100644 --- a/av/frame.py +++ b/av/frame.py @@ -1,3 +1,5 @@ +from enum import IntFlag + import cython from cython.cimports.av.error import err_check from cython.cimports.av.opaque import opaque_container @@ -10,6 +12,13 @@ from av.sidedata.sidedata import SideDataContainer +class DecodeError(IntFlag): + invalid_bitstream = lib.FF_DECODE_ERROR_INVALID_BITSTREAM + missing_reference = lib.FF_DECODE_ERROR_MISSING_REFERENCE + concealment_active = lib.FF_DECODE_ERROR_CONCEALMENT_ACTIVE + decode_slices = lib.FF_DECODE_ERROR_DECODE_SLICES + + @cython.cclass class Frame: """ @@ -186,6 +195,19 @@ def is_corrupt(self): self.ptr.flags & lib.AV_FRAME_FLAG_CORRUPT ) + @property + def decode_error_flags(self): + """What went wrong while decoding this frame, if anything. + + The decoder still produced the frame, but it may contain artifacts. + Empty for frames that decoded cleanly. + + Wraps :ffmpeg:`AVFrame.decode_error_flags`. + + :type: DecodeError + """ + return DecodeError(self.ptr.decode_error_flags) + @property def key_frame(self): """Is this frame a key frame? diff --git a/av/frame.pyi b/av/frame.pyi index a5860723b..d5ad001de 100644 --- a/av/frame.pyi +++ b/av/frame.pyi @@ -1,3 +1,4 @@ +from enum import IntFlag from fractions import Fraction from typing_extensions import disjoint_base @@ -5,6 +6,12 @@ from typing_extensions import disjoint_base from av.rational import AVRational from av.sidedata.sidedata import SideDataContainer +class DecodeError(IntFlag): + invalid_bitstream = ... + missing_reference = ... + concealment_active = ... + decode_slices = ... + @disjoint_base class Frame: dts: int | None @@ -26,5 +33,7 @@ class Frame: @property def is_corrupt(self) -> bool: ... @property + def decode_error_flags(self) -> DecodeError: ... + @property def key_frame(self) -> bool: ... def make_writable(self) -> None: ... diff --git a/av/video/frame.py b/av/video/frame.py index 7000ec0d1..b1bdcf8e2 100644 --- a/av/video/frame.py +++ b/av/video/frame.py @@ -714,6 +714,82 @@ def interlaced_frame(self): return bool(self.ptr.flags & lib.AV_FRAME_FLAG_INTERLACED) + @property + def crop_top(self): + """Pixels to crop from the top of the frame. + + Decoders apply cropping themselves unless the ``apply_cropping`` codec + option is ``"0"``, in which case the crop is reported here and can be + applied with :meth:`apply_cropping`. + + Wraps :ffmpeg:`AVFrame.crop_top`. + + :type: int + """ + return self.ptr.crop_top + + @crop_top.setter + def crop_top(self, value): + self.ptr.crop_top = value + + @property + def crop_bottom(self): + """Pixels to crop from the bottom of the frame. See :attr:`crop_top`. + + Wraps :ffmpeg:`AVFrame.crop_bottom`. + + :type: int + """ + return self.ptr.crop_bottom + + @crop_bottom.setter + def crop_bottom(self, value): + self.ptr.crop_bottom = value + + @property + def crop_left(self): + """Pixels to crop from the left of the frame. See :attr:`crop_top`. + + Wraps :ffmpeg:`AVFrame.crop_left`. + + :type: int + """ + return self.ptr.crop_left + + @crop_left.setter + def crop_left(self, value): + self.ptr.crop_left = value + + @property + def crop_right(self): + """Pixels to crop from the right of the frame. See :attr:`crop_top`. + + Wraps :ffmpeg:`AVFrame.crop_right`. + + :type: int + """ + return self.ptr.crop_right + + @crop_right.setter + def crop_right(self, value): + self.ptr.crop_right = value + + def apply_cropping(self, unaligned=False): + """Crop the frame in place according to its ``crop_*`` attributes. + + No pixels are copied; the planes are narrowed to the cropped region and + the ``crop_*`` attributes are reset to 0. + + By default the left edge may be moved slightly to keep plane data + aligned, leaving a few extra pixels. Pass ``unaligned=True`` to crop + exactly. + + Wraps :ffmpeg:`av_frame_apply_cropping`. + """ + flags: cython.int = lib.AV_FRAME_CROP_UNALIGNED if unaligned else 0 + err_check(lib.av_frame_apply_cropping(self.ptr, flags)) + self._init_user_attributes() + @property def pict_type(self): """Returns an integer that corresponds to the PictureType enum. @@ -728,6 +804,26 @@ def pict_type(self): def pict_type(self, value): self.ptr.pict_type = value + @property + def repeat_pict(self): + """How many extra fields to show this frame for, as used by soft + telecine. + + The frame lasts ``repeat_pict + 2`` field durations, so ``1`` on an + interlaced frame means it is shown as 3 fields. Decoders set this from + MPEG-2 ``repeat_first_field`` or H.264 ``pic_struct``. Few encoders + read it; Intel QSV is one. + + Wraps :ffmpeg:`AVFrame.repeat_pict`. + + :type: int + """ + return self.ptr.repeat_pict + + @repeat_pict.setter + def repeat_pict(self, value): + self.ptr.repeat_pict = value + @property def colorspace(self): """The YUV/RGB transformation matrix of the frame, as FFmpeg's raw diff --git a/av/video/frame.pyi b/av/video/frame.pyi index b9dc83806..a2a5f1f78 100644 --- a/av/video/frame.pyi +++ b/av/video/frame.pyi @@ -69,6 +69,7 @@ class VideoFrame(Frame): format: VideoFormat planes: tuple[VideoPlane, ...] pict_type: int + repeat_pict: int colorspace: int color_range: int color_trc: int @@ -83,6 +84,11 @@ class VideoFrame(Frame): def height(self) -> int: ... @property def interlaced_frame(self) -> bool: ... + crop_top: int + crop_bottom: int + crop_left: int + crop_right: int + def apply_cropping(self, unaligned: bool = False) -> None: ... @property def rotation(self) -> int: ... def __new__( diff --git a/av/video/plane.py b/av/video/plane.py index 51a3a13f1..2d056103f 100644 --- a/av/video/plane.py +++ b/av/video/plane.py @@ -51,6 +51,22 @@ def __cinit__(self, frame: VideoFrame, index: cython.int): # ignore it's direction. self.buffer_size = abs(self.frame.ptr.linesize[self.index]) * self.height + # Cropping moves the data pointer into the buffer, so the last row can end + # past the allocation. Clamp to what the plane's buffer actually holds. + if frame.ptr.hw_frames_ctx or self.frame.ptr.linesize[self.index] < 0: + return + buf: cython.pointer[lib.AVBufferRef] = lib.av_frame_get_plane_buffer( + frame.ptr, index + ) + if buf == cython.NULL: + return + start: cython.p_uchar = frame.ptr.data[index] + end: cython.p_uchar = buf.data + buf.size + if buf.data <= start < end and cython.cast(cython.size_t, end - start) < ( + self.buffer_size + ): + self.buffer_size = end - start + @cython.cfunc def _buffer_size(self) -> cython.size_t: return self.buffer_size diff --git a/av/video/stream.py b/av/video/stream.py index ebdc1fa68..60b12941f 100644 --- a/av/video/stream.py +++ b/av/video/stream.py @@ -259,6 +259,32 @@ def guessed_rate(self): ) return from_avrational(val) + @property + def codec_rate(self): + """The frame rate stored in the codec bitstream, such as H.264 VUI + timing info. + + Is falsy if the bitstream does not say. + + Wraps :ffmpeg:`AVCodecParameters.framerate`. + + :type: AVRational + """ + self._assert_open() + return from_avrational(self.ptr.codecpar.framerate) + + @property + def video_delay(self): + """The number of frames the decoder holds back before output, due to + frame reordering. + + Wraps :ffmpeg:`AVCodecParameters.video_delay`. + + :type: int + """ + self._assert_open() + return self.ptr.codecpar.video_delay + @property def sample_aspect_ratio(self): """The guessed sample aspect ratio (SAR) of this stream. diff --git a/av/video/stream.pyi b/av/video/stream.pyi index cfe656ead..b026ffe8c 100644 --- a/av/video/stream.pyi +++ b/av/video/stream.pyi @@ -36,6 +36,8 @@ class VideoStream(Stream): average_rate: AVRational base_rate: AVRational guessed_rate: AVRational + codec_rate: AVRational + video_delay: int # from codec context name: str diff --git a/include/avcodec.pxd b/include/avcodec.pxd index 03556dd6e..045600db1 100644 --- a/include/avcodec.pxd +++ b/include/avcodec.pxd @@ -115,6 +115,12 @@ cdef extern from "libavcodec/avcodec.h" nogil: cdef enum: FF_COMPLIANCE_NORMAL + cdef enum: + FF_DECODE_ERROR_INVALID_BITSTREAM + FF_DECODE_ERROR_MISSING_REFERENCE + FF_DECODE_ERROR_CONCEALMENT_ACTIVE + FF_DECODE_ERROR_DECODE_SLICES + cdef enum AVCodecID: AV_CODEC_ID_NONE AV_CODEC_ID_PCM_ALAW @@ -167,7 +173,9 @@ cdef extern from "libavcodec/avcodec.h" nogil: AVMediaType type AVCodecID id int capabilities + uint8_t max_lowres const AVClass *priv_class + const char *wrapper_name cdef int av_codec_is_encoder(const AVCodec*) cdef int av_codec_is_decoder(const AVCodec*) @@ -370,6 +378,7 @@ cdef extern from "libavcodec/avcodec.h" nogil: int nb_samples int format # -1 if unknown, AVPixelFormat or AVSampleFormat AVPictureType pict_type + int repeat_pict int64_t pts int64_t pkt_dts @@ -394,6 +403,10 @@ cdef extern from "libavcodec/avcodec.h" nogil: AVBufferRef *opaque_ref AVChannelLayout ch_layout int64_t duration + size_t crop_top + size_t crop_bottom + size_t crop_left + size_t crop_right cdef struct AVPacket: AVBufferRef *buf @@ -475,7 +488,7 @@ cdef extern from "libavcodec/avcodec.h" nogil: int duration int key_frame - cdef AVCodecParserContext *av_parser_init(int codec_id) + cdef AVCodecParserContext *av_parser_init(AVCodecID codec_id) cdef int av_parser_parse2( AVCodecParserContext *s, AVCodecContext *avctx, @@ -493,6 +506,8 @@ cdef extern from "libavcodec/avcodec.h" nogil: int extradata_size int width int height + AVRational framerate + int video_delay int sample_rate AVPacketSideData *coded_side_data int nb_coded_side_data diff --git a/include/avformat.pxd b/include/avformat.pxd index 318205433..aaea45138 100644 --- a/include/avformat.pxd +++ b/include/avformat.pxd @@ -125,6 +125,11 @@ cdef extern from "libavformat/avformat.h" nogil: AVFMT_FLAG_FAST_SEEK AVFMT_FLAG_AUTO_BSF + cdef enum AVDurationEstimationMethod: + AVFMT_DURATION_FROM_PTS + AVFMT_DURATION_FROM_STREAM + AVFMT_DURATION_FROM_BITRATE + cdef int av_find_best_stream( AVFormatContext *ic, AVMediaType type, @@ -156,6 +161,8 @@ cdef extern from "libavformat/avformat.h" nogil: AVCodecID audio_codec_id AVCodecID video_codec_id void *opaque + int probe_score + AVDurationEstimationMethod duration_estimation_method int (*io_open)( AVFormatContext *s, AVIOContext **pb, diff --git a/include/avutil.pxd b/include/avutil.pxd index c9749fa8f..197afedfc 100644 --- a/include/avutil.pxd +++ b/include/avutil.pxd @@ -188,6 +188,11 @@ cdef extern from "libavutil/frame.h" nogil: cdef int av_frame_get_buffer(AVFrame *frame, int align) cdef int av_frame_make_writable(AVFrame *frame) cdef int av_frame_copy_props(AVFrame *dst, const AVFrame *src) + cdef int av_frame_apply_cropping(AVFrame *frame, int flags) + cdef AVBufferRef *av_frame_get_plane_buffer(const AVFrame *frame, int plane) + + cdef enum: + AV_FRAME_CROP_UNALIGNED cdef extern from "libavutil/hwcontext.h" nogil: cdef struct AVHWDeviceContext: diff --git a/tests/test_codec.py b/tests/test_codec.py index 315df5646..929f6e036 100644 --- a/tests/test_codec.py +++ b/tests/test_codec.py @@ -23,6 +23,8 @@ def test_codec_mpeg4_decoder() -> None: assert c.is_decoder assert not c.is_encoder assert c.delay + assert c.wrapper_name is None + assert c.max_lowres == 3 assert c.audio_formats is None and c.audio_rates is None @@ -89,6 +91,12 @@ def test_codec_opus_encoder() -> None: assert c.video_formats is None and c.frame_rates is None +def test_codec_wrapper_name() -> None: + if "aac_at" not in codecs_available: + pytest.skip("aac_at not available") + assert Codec("aac_at").wrapper_name == "at" + + def test_codecs_available() -> None: assert codecs_available diff --git a/tests/test_file_probing.py b/tests/test_file_probing.py index fa49a9420..b94c1e8f7 100644 --- a/tests/test_file_probing.py +++ b/tests/test_file_probing.py @@ -1,5 +1,8 @@ +import io from fractions import Fraction +import numpy as np + import av from .common import TestCase, fate_suite @@ -12,6 +15,7 @@ def setUp(self) -> None: def test_container_probing(self) -> None: assert self.file.bit_rate == 269558 assert self.file.duration == 6165333 + assert self.file.duration_estimation_method == "pts" assert str(self.file.format) == "" assert self.file.format.name == "mpegts" assert self.file.format.long_name == "MPEG-TS (MPEG-2 Transport Stream)" @@ -170,6 +174,7 @@ def setUp(self) -> None: def test_container_probing(self) -> None: assert self.file.bit_rate == 810 assert self.file.duration == 8140000 + assert self.file.duration_estimation_method == "stream" assert str(self.file.format) == "" assert self.file.format.name == "mov,mp4,m4a,3gp,3g2,mj2" assert self.file.format.long_name == "QuickTime / MOV" @@ -222,6 +227,8 @@ def test_container_probing(self) -> None: assert self.file.metadata == {} assert self.file.size == 800000 assert self.file.start_time == 22953408322 + # The mpegts prober scores this file 100 on FFmpeg 9.0 but 50 on 8.1. + assert self.file.probe_score >= 50 assert len(self.file.streams) == 1 def test_stream_probing(self) -> None: @@ -234,6 +241,8 @@ def test_stream_probing(self) -> None: # actual stream properties assert stream.average_rate == Fraction(25, 1) + assert stream.codec_rate == Fraction(25, 1) + assert stream.video_delay == 0 assert stream.duration == 145800 assert stream.frames == 0 assert stream.id == 4131 @@ -316,3 +325,40 @@ def test_stream_probing(self) -> None: assert stream.coded_width == 0 assert stream.coded_height == 0 + + +def _adts_bytes() -> bytes: + buf = io.BytesIO() + with av.open(buf, "w", format="adts") as out: + stream = out.add_stream("aac", rate=48000) + for i in range(50): + frame = av.AudioFrame.from_ndarray( + np.zeros((2, 1024), dtype=np.float32), format="fltp", layout="stereo" + ) + frame.sample_rate = 48000 + frame.pts = i * 1024 + out.mux(stream.encode(frame)) + out.mux(stream.encode(None)) + return buf.getvalue() + + +class _Pipe: + def __init__(self, data: bytes) -> None: + self._buf = io.BytesIO(data) + + def read(self, n: int) -> bytes: + return self._buf.read(n) + + +def test_duration_estimation_method_bitrate() -> None: + # Raw ADTS has no duration, so it is guessed from the file size. + with av.open(io.BytesIO(_adts_bytes()), "r") as container: + assert container.duration is not None + assert container.duration_estimation_method == "bitrate" + + +def test_duration_estimation_method_unknown() -> None: + # A pipe has no size to estimate from. + with av.open(_Pipe(_adts_bytes()), "r") as container: + assert container.duration is None + assert container.duration_estimation_method is None diff --git a/tests/test_videoframe.py b/tests/test_videoframe.py index fa67df60b..284ae32f0 100644 --- a/tests/test_videoframe.py +++ b/tests/test_videoframe.py @@ -1,4 +1,5 @@ import gc +import io import weakref from fractions import Fraction from pathlib import Path @@ -9,6 +10,7 @@ import av import av.filter from av import VideoFrame +from av.frame import DecodeError from av.video.frame import supported_np_pix_fmts from av.video.reformatter import Colorspace, Interpolation @@ -1534,3 +1536,112 @@ def test_save_options(tmp_path: Path) -> None: # Non-str values are coerced. frame.save(tmp_path / "q.jpg", qscale=2) assert (tmp_path / "q.jpg").stat().st_size > 0 + + +def test_crop_fields_from_decoder() -> None: + path = fate_suite("h264/interlaced_crop.mp4") + with av.open(path) as container: + ref = next(container.decode(video=0)) + assert ref.crop_top == ref.crop_bottom == ref.crop_left == ref.crop_right == 0 + + with av.open(path) as container: + stream = container.streams.video[0] + stream.cctx.options = {"apply_cropping": "0"} + frame = next(container.decode(stream)) + + assert frame.height == ref.height + 24 + assert frame.crop_bottom == 24 + + frame.apply_cropping() + assert (frame.width, frame.height) == (ref.width, ref.height) + assert frame.crop_bottom == 0 + assert frame.format.height == ref.height + assertNdarraysEqual(frame.to_ndarray(format="gray"), ref.to_ndarray(format="gray")) + + +def test_apply_cropping_unaligned() -> None: + array = numpy.arange(48 * 64 * 3, dtype=numpy.uint8).reshape(48, 64, 3) + frame = VideoFrame.from_ndarray(array, format="rgb24") + frame.crop_top = 4 + frame.crop_left = 10 + frame.crop_right = 6 + frame.apply_cropping(unaligned=True) + + assert (frame.width, frame.height) == (48, 44) + assertNdarraysEqual(frame.to_ndarray(), array[4:, 10:58]) + + +def test_apply_cropping_plane_stays_in_buffer() -> None: + array = numpy.arange(48 * 64 * 3, dtype=numpy.uint8).reshape(48, 64, 3) + frame = VideoFrame.from_numpy_buffer(array, format="rgb24") + frame.crop_left = 10 + frame.apply_cropping(unaligned=True) + + # The last row ends 30 bytes before linesize * height would reach. + assert frame.planes[0].buffer_size == array.nbytes - 30 + assertNdarraysEqual(frame.to_ndarray(), array[:, 10:]) + + +def test_apply_cropping_invalid() -> None: + frame = VideoFrame(64, 48, "yuv420p") + frame.crop_left = 100 + with pytest.raises(av.error.OverflowError): + frame.apply_cropping() + + +def _encode_m2v() -> bytearray: + buf = io.BytesIO() + with av.open(buf, "w", format="mpeg2video") as out: + stream = out.add_stream("mpeg2video", rate=25) + stream.width = 64 + stream.height = 48 + stream.pix_fmt = "yuv420p" + for i in range(6): + frame = VideoFrame.solid(64, 48, (i * 40, 0, 0)) + frame.pts = i + out.mux(stream.encode(frame)) + out.mux(stream.encode(None)) + return bytearray(buf.getvalue()) + + +def _decode_m2v(data: bytearray) -> list[VideoFrame]: + with av.open(io.BytesIO(bytes(data)), "r", format="mpeg2video") as container: + return list(container.decode(video=0)) + + +def test_repeat_pict() -> None: + data = _encode_m2v() + assert all(frame.repeat_pict == 0 for frame in _decode_m2v(data)) + + # Set repeat_first_field and clear top_field_first in every picture coding + # extension. On a progressive sequence, that doubles each frame. + i = data.find(b"\x00\x00\x01\xb5") + while i != -1: + if data[i + 4] >> 4 == 8: + data[i + 7] = (data[i + 7] | 0x02) & ~0x80 + i = data.find(b"\x00\x00\x01\xb5", i + 4) + + frames = _decode_m2v(data) + assert frames + assert all(frame.repeat_pict == 2 for frame in frames) + + +def test_decode_error_flags() -> None: + data = _encode_m2v() + for frame in _decode_m2v(data): + assert frame.decode_error_flags == DecodeError(0) + assert not frame.is_corrupt + + # Garble the start of every slice after the first frame's. + slices = [ + i + for i in range(len(data) - 3) + if data[i : i + 3] == b"\x00\x00\x01" and 0x01 <= data[i + 3] <= 0xAF + ] + for i in slices[2:]: + for j in range(i + 5, min(i + 12, len(data))): + data[j] ^= 0xFF + + frames = _decode_m2v(data) + assert any(DecodeError.concealment_active in f.decode_error_flags for f in frames) + assert any(frame.is_corrupt for frame in frames)