11import io
22
3+ import numpy as np
4+ import pytest
5+
36import av
47import av .datasets
58
@@ -16,7 +19,7 @@ def test_video_remux() -> None:
1619 out_stream = output .add_stream_from_template (in_stream )
1720
1821 for packet in input_ .demux (in_stream ):
19- if packet .dts is None :
22+ if packet .size == 0 : # skip the flushing packet, not keyframes with no DTS
2023 continue
2124
2225 packet .stream = out_stream
@@ -58,7 +61,7 @@ def test_add_mux_stream_video() -> None:
5861 out_stream .time_base = in_stream .time_base
5962
6063 for packet in input_ .demux (in_stream ):
61- if packet .dts is None :
64+ if packet .size == 0 :
6265 continue
6366 packet .stream = out_stream
6467 output .mux (packet )
@@ -109,3 +112,79 @@ def test_add_stream_from_template_copies_time_base() -> None:
109112 out_audio = output .add_stream_from_template (in_audio )
110113 assert out_audio .time_base is not None
111114 assert out_audio .time_base == in_audio .time_base
115+
116+
117+ def _make_b_frame_mkv (n : int = 48 ) -> io .BytesIO :
118+ """Encode `n` frames with B-frames into an in-memory MKV.
119+
120+ Matroska stores only presentation timestamps, so when this is demuxed again
121+ libavformat cannot reconstruct a DTS for the leading reordered packets and
122+ leaves it as None -- including on the very first packet, which is the
123+ keyframe. That is exactly the layout that broke the remux example in #1917.
124+ """
125+ buf = io .BytesIO ()
126+ with av .open (buf , "w" , format = "matroska" ) as out :
127+ stream = out .add_stream ("h264" , rate = 30 )
128+ stream .width , stream .height , stream .pix_fmt = 160 , 120 , "yuv420p"
129+ stream .options = {"bf" : "3" , "g" : "30" }
130+ for i in range (n ):
131+ img = np .full ((120 , 160 , 3 ), (i * 5 ) % 256 , dtype = "uint8" )
132+ frame = av .VideoFrame .from_ndarray (img , format = "rgb24" )
133+ for packet in stream .encode (frame ):
134+ out .mux (packet )
135+ for packet in stream .encode (None ):
136+ out .mux (packet )
137+ buf .seek (0 )
138+ return buf
139+
140+
141+ def _decoded_frame_count (buf : io .BytesIO ) -> int :
142+ buf .seek (0 )
143+ with av .open (buf , "r" ) as container :
144+ return sum (1 for _ in container .decode (video = 0 ))
145+
146+
147+ def test_remux_keeps_keyframe_with_none_dts () -> None :
148+ """Regression test for #1917.
149+
150+ A keyframe can legitimately demux with ``dts is None`` (B-frame stream in a
151+ PTS-only container like MKV). The remux loop must skip only the empty
152+ flushing packet (``size == 0``), not every ``dts is None`` packet, otherwise
153+ the keyframe is dropped and the output is undecodable.
154+ """
155+ if av .codec .Codec ("h264" , "w" ).name != "libx264" :
156+ pytest .skip ("requires libx264" )
157+
158+ source = _make_b_frame_mkv ()
159+ expected_frames = _decoded_frame_count (source )
160+ assert expected_frames > 0
161+
162+ # Precondition: the first packet really is a keyframe without a DTS, which is
163+ # what the old `dts is None` filter would have wrongly discarded.
164+ source .seek (0 )
165+ with av .open (source , "r" ) as input_ :
166+ first = next (p for p in input_ .demux (input_ .streams .video [0 ]) if p .size )
167+ assert first .is_keyframe
168+ assert first .dts is None
169+
170+ source .seek (0 )
171+ output = io .BytesIO ()
172+ with (
173+ av .open (source , "r" ) as input_ ,
174+ av .open (output , "w" , format = "matroska" ) as out ,
175+ ):
176+ in_video = input_ .streams .video [0 ]
177+ out_video = out .add_stream_from_template (in_video )
178+ for packet in input_ .demux (in_video ):
179+ if packet .size == 0 : # the flushing packet, not a keyframe with no DTS
180+ continue
181+ packet .stream = out_video
182+ out .mux (packet )
183+
184+ # The keyframe survived: every frame still decodes and the first packet of
185+ # the remuxed stream is a keyframe.
186+ assert _decoded_frame_count (output ) == expected_frames
187+ output .seek (0 )
188+ with av .open (output , "r" ) as container :
189+ first_out = next (p for p in container .demux (video = 0 ) if p .size )
190+ assert first_out .is_keyframe
0 commit comments