Skip to content

Commit 2695cee

Browse files
committed
Fix remux examples dropping keyframes with no DTS (#1917)
The remux examples skipped demux's flushing packet with `packet.dts is None`. That assumption is wrong: a valid keyframe can demux with no DTS (e.g. the leading reordered packets of a B-frame stream in a PTS-only container like MKV). For such files the keyframe was dropped, producing an undecodable video that only played the audio. Skip the empty flushing packet with `packet.size == 0` instead, and apply the same fix to the remux loops in the test suite. Adds a regression test that encodes a B-frame MKV (whose first packet is a keyframe with no DTS) and asserts the remux keeps it.
1 parent f235d80 commit 2695cee

6 files changed

Lines changed: 91 additions & 9 deletions

File tree

examples/basics/remux.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@
1414
for packet in input_.demux(in_stream):
1515
print(packet)
1616

17-
# We need to skip the "flushing" packets that `demux` generates.
18-
if packet.dts is None:
17+
# We need to skip the empty "flushing" packet that `demux` generates at the
18+
# end. Don't test `packet.dts is None` here: a valid keyframe can legitimately
19+
# have no DTS (e.g. the leading reordered packets of a B-frame stream in MKV),
20+
# and skipping it would drop the keyframe and corrupt the output.
21+
if packet.size == 0:
1922
continue
2023

2124
# We need to assign the packet to the new stream.

examples/subtitles/remux.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
out_stream = output.add_stream_from_template(in_stream)
1010

1111
for packet in input_.demux(in_stream):
12-
if packet.dts is None:
12+
if packet.size == 0:
1313
continue
1414
packet.stream = out_stream
1515
output.mux(packet)

tests/test_encode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def test_subtitle_muxing(self) -> None:
233233
out_stream = output.add_stream_from_template(in_stream)
234234

235235
for packet in input_.demux(in_stream):
236-
if packet.dts is None:
236+
if packet.size == 0:
237237
continue
238238
packet.stream = out_stream
239239
output.mux(packet)

tests/test_packet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def test_skip_samples_remux(self) -> None:
197197
with av.open(output_path, "w") as out:
198198
out_stream = out.add_stream_from_template(audio_stream)
199199
for pkt in inp.demux(audio_stream):
200-
if pkt.dts is None:
200+
if pkt.size == 0:
201201
continue
202202
if pkt.has_sidedata("skip_samples"):
203203
sdata = pkt.get_sidedata("skip_samples")

tests/test_remux.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import io
22

3+
import numpy as np
4+
import pytest
5+
36
import av
47
import 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

tests/test_streams.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def test_attachment_stream(self) -> None:
360360
out_v = out1.add_stream_from_template(in_v)
361361

362362
for packet in input_.demux(in_v):
363-
if packet.dts is None:
363+
if packet.size == 0:
364364
continue
365365
packet.stream = out_v
366366
out1.mux(packet)
@@ -382,7 +382,7 @@ def test_attachment_stream(self) -> None:
382382
stream_map[s.index] = oc.add_stream_from_template(s)
383383

384384
for packet in ic.demux(ic.streams.video):
385-
if packet.dts is None:
385+
if packet.size == 0:
386386
continue
387387
updated_stream = stream_map.get(packet.stream.index)
388388
if isinstance(updated_stream, av.video.stream.VideoStream):

0 commit comments

Comments
 (0)