Skip to content

Commit 5dc4d94

Browse files
Merge pull request #2413 from ArtsemKurantsou:rtap-h265-aggregation-packet-support
PiperOrigin-RevId: 776088856
2 parents 6bfda82 + e04c3af commit 5dc4d94

File tree

3 files changed

+359
-2
lines changed

3 files changed

+359
-2
lines changed

‎RELEASENOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
* DASH extension:
3939
* Smooth Streaming extension:
4040
* RTSP extension:
41+
* Add support for RTP Aggregation Packet for H265 in accordance with RFC
42+
7798#4.4.2 ([#2413](https://github.com/androidx/media/pull/2413)).
4143
* Decoder extensions (FFmpeg, VP9, AV1, etc.):
4244
* MIDI extension:
4345
* Leanback extension:

‎libraries/exoplayer_rtsp/src/main/java/androidx/media3/exoplayer/rtsp/reader/RtpH265Reader.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,7 @@ public void consume(ParsableByteArray data, long timestamp, int sequenceNumber,
102102
if (payloadType >= 0 && payloadType < RTP_PACKET_TYPE_AP) {
103103
processSingleNalUnitPacket(data);
104104
} else if (payloadType == RTP_PACKET_TYPE_AP) {
105-
// TODO: Support AggregationPacket mode.
106-
throw new UnsupportedOperationException("need to implement processAggregationPacket");
105+
processAggregationPacket(data);
107106
} else if (payloadType == RTP_PACKET_TYPE_FU) {
108107
processFragmentationUnitPacket(data, sequenceNumber);
109108
} else {
@@ -167,6 +166,68 @@ private void processSingleNalUnitPacket(ParsableByteArray data) {
167166
bufferFlags = getBufferFlagsFromNalType(nalHeaderType);
168167
}
169168

169+
/**
170+
* Processes Aggregation packet (RFC7798 Section 4.4.2).
171+
*
172+
* <p>Outputs 2 or more NAL Unit (with start code prepended) to {@link #trackOutput}. Sets {@link
173+
* #bufferFlags} and {@link #fragmentedSampleSizeBytes} accordingly.
174+
*/
175+
@RequiresNonNull("trackOutput")
176+
private void processAggregationPacket(ParsableByteArray data) throws ParserException {
177+
// The structure of an Aggregation Packet.
178+
// 0 1 2 3
179+
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
180+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
181+
// | PayloadHdr (Type=48) | |
182+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
183+
// | |
184+
// | two or more aggregation units |
185+
// | |
186+
// | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
187+
// | :...OPTIONAL RTP padding |
188+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
189+
190+
// The structure of aggregation unit
191+
// 0 1 2 3
192+
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
193+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
194+
// : DONL (conditional) | NALU size |
195+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
196+
// | NALU size | |
197+
// +-+-+-+-+-+-+-+-+ NAL unit |
198+
// | |
199+
// | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
200+
// | :
201+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
202+
203+
// Since sprop-max-don-diff != 0 is not supported, DONL won't present in the packet.
204+
205+
int nalUnitsCount = 0;
206+
data.setPosition(2); // skipping payload header (2 bytes)
207+
while (data.bytesLeft() > 2) {
208+
int nalUnitSize = data.readUnsignedShort(); // 2 bytes of NAL unit size
209+
int nalHeaderType = NalUnitUtil.getH265NalUnitType(data.getData(), data.getPosition() - 3);
210+
if (data.bytesLeft() < nalUnitSize) {
211+
throw ParserException.createForMalformedManifest(
212+
"Malformed Aggregation Packet. NAL unit size exceeds packet size.", /* cause= */ null);
213+
}
214+
215+
fragmentedSampleSizeBytes += writeStartCode();
216+
trackOutput.sampleData(data, nalUnitSize);
217+
fragmentedSampleSizeBytes += nalUnitSize;
218+
bufferFlags |= getBufferFlagsFromNalType(nalHeaderType);
219+
nalUnitsCount++;
220+
}
221+
if (data.bytesLeft() > 0) {
222+
throw ParserException.createForMalformedManifest(
223+
"Malformed Aggregation Packet. Packet size exceeds NAL unit size.", /* cause= */ null);
224+
}
225+
if (nalUnitsCount < 2) {
226+
throw ParserException.createForMalformedManifest(
227+
"Aggregation Packet must contain at least 2 NAL units.", /* cause= */ null);
228+
}
229+
}
230+
170231
/**
171232
* Processes Fragmentation Unit packet (RFC7798 Section 4.4.3).
172233
*
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package androidx.media3.exoplayer.rtsp.reader;
17+
18+
import static androidx.media3.common.util.Util.getBytesFromHexString;
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertThrows;
21+
22+
import androidx.media3.common.C;
23+
import androidx.media3.common.Format;
24+
import androidx.media3.common.MimeTypes;
25+
import androidx.media3.common.ParserException;
26+
import androidx.media3.common.util.ParsableByteArray;
27+
import androidx.media3.common.util.Util;
28+
import androidx.media3.container.NalUnitUtil;
29+
import androidx.media3.exoplayer.rtsp.RtpPacket;
30+
import androidx.media3.exoplayer.rtsp.RtpPayloadFormat;
31+
import androidx.media3.test.utils.FakeExtractorOutput;
32+
import androidx.media3.test.utils.FakeTrackOutput;
33+
import androidx.test.ext.junit.runners.AndroidJUnit4;
34+
import com.google.common.collect.ImmutableMap;
35+
import com.google.common.primitives.Bytes;
36+
import java.util.Arrays;
37+
import org.junit.Before;
38+
import org.junit.Test;
39+
import org.junit.runner.RunWith;
40+
41+
/** Unit test for {@link RtpH265Reader}. */
42+
@RunWith(AndroidJUnit4.class)
43+
public class RtpH265ReaderTest {
44+
45+
private static final long MEDIA_CLOCK_FREQUENCY = 90_000;
46+
private static final long AP_PACKET_RTP_TIMESTAMP = 9_000_000;
47+
private static final long AP_PACKET_2_RTP_TIMESTAMP = 9_000_040;
48+
private static final int AP_PACKET_SEQUENCE_NUMBER = 12345;
49+
private static final long SINGLE_NALU_PACKET_1_RTP_TIMESTAMP = 9_000_040;
50+
private static final long SINGLE_NALU_PACKET_2_RTP_TIMESTAMP = 9_000_080;
51+
52+
private static final byte[] AP_NALU_HEADER = getBytesFromHexString("6001");
53+
private static final byte[] NALU_1_LENGTH = getBytesFromHexString("000c");
54+
private static final byte[] NALU_1_INVALID_LENGTH = getBytesFromHexString("00ff");
55+
private static final byte[] NALU_1_HEADER = getBytesFromHexString("4001");
56+
private static final byte[] NALU_1_PAYLOAD = getBytesFromHexString("0102030405060708090a");
57+
private static final byte[] NALU_2_LENGTH = getBytesFromHexString("000e");
58+
private static final byte[] NALU_2_HEADER = getBytesFromHexString("4201");
59+
private static final byte[] NALU_2_PAYLOAD = getBytesFromHexString("1112131415161718191a1b1c");
60+
61+
private static final RtpPacket SINGLE_NALU_PACKET_1 =
62+
new RtpPacket.Builder()
63+
.setTimestamp(SINGLE_NALU_PACKET_1_RTP_TIMESTAMP)
64+
.setSequenceNumber(AP_PACKET_SEQUENCE_NUMBER + 1)
65+
.setMarker(true)
66+
.setPayloadData(Bytes.concat(NALU_1_HEADER, NALU_1_PAYLOAD))
67+
.build();
68+
69+
private static final RtpPacket SINGLE_NALU_PACKET_2 =
70+
new RtpPacket.Builder()
71+
.setTimestamp(SINGLE_NALU_PACKET_2_RTP_TIMESTAMP)
72+
.setSequenceNumber(AP_PACKET_SEQUENCE_NUMBER + 2)
73+
.setMarker(true)
74+
.setPayloadData(Bytes.concat(NALU_2_HEADER, NALU_2_PAYLOAD))
75+
.build();
76+
77+
private static final RtpPacket VALID_AP_PACKET =
78+
createAggregationPacket(
79+
AP_PACKET_SEQUENCE_NUMBER,
80+
AP_PACKET_RTP_TIMESTAMP,
81+
NALU_1_LENGTH,
82+
NALU_1_HEADER,
83+
NALU_1_PAYLOAD,
84+
NALU_2_LENGTH,
85+
NALU_2_HEADER,
86+
NALU_2_PAYLOAD);
87+
88+
private static final RtpPacket VALID_AP_PACKET_2 =
89+
createAggregationPacket(
90+
AP_PACKET_SEQUENCE_NUMBER + 1,
91+
AP_PACKET_2_RTP_TIMESTAMP,
92+
NALU_1_LENGTH,
93+
NALU_1_HEADER,
94+
NALU_1_PAYLOAD,
95+
NALU_2_LENGTH,
96+
NALU_2_HEADER,
97+
NALU_2_PAYLOAD);
98+
99+
private static final RtpPacket INVALID_AP_PACKET_EXTRA_BYTE =
100+
createAggregationPacket(
101+
AP_PACKET_SEQUENCE_NUMBER,
102+
AP_PACKET_RTP_TIMESTAMP,
103+
NALU_1_LENGTH,
104+
NALU_1_HEADER,
105+
NALU_1_PAYLOAD,
106+
NALU_2_LENGTH,
107+
NALU_2_HEADER,
108+
NALU_2_PAYLOAD,
109+
new byte[] {0x0a});
110+
111+
private static final RtpPacket INVALID_AP_PACKET_MISSING_BYTE =
112+
createAggregationPacket(
113+
AP_PACKET_SEQUENCE_NUMBER,
114+
AP_PACKET_RTP_TIMESTAMP,
115+
NALU_1_LENGTH,
116+
NALU_1_HEADER,
117+
NALU_1_PAYLOAD,
118+
NALU_2_LENGTH,
119+
NALU_2_HEADER,
120+
Arrays.copyOf(NALU_2_PAYLOAD, NALU_2_PAYLOAD.length - 1));
121+
122+
private static final RtpPacket INVALID_AP_PACKET_INVALID_NALU_LENGTH =
123+
createAggregationPacket(
124+
AP_PACKET_SEQUENCE_NUMBER,
125+
AP_PACKET_RTP_TIMESTAMP,
126+
NALU_1_INVALID_LENGTH,
127+
NALU_1_HEADER,
128+
NALU_1_PAYLOAD,
129+
NALU_2_LENGTH,
130+
NALU_2_HEADER);
131+
132+
private static final RtpPacket INVALID_AP_PACKET_SINGLE_NALU =
133+
createAggregationPacket(
134+
AP_PACKET_SEQUENCE_NUMBER,
135+
AP_PACKET_RTP_TIMESTAMP,
136+
NALU_1_LENGTH,
137+
NALU_1_HEADER,
138+
NALU_1_PAYLOAD);
139+
140+
private static final RtpPayloadFormat H265_FORMAT =
141+
new RtpPayloadFormat(
142+
new Format.Builder()
143+
.setSampleMimeType(MimeTypes.VIDEO_H265)
144+
.setWidth(1920)
145+
.setHeight(1080)
146+
.build(),
147+
/* rtpPayloadType= */ 98,
148+
/* clockRate= */ (int) MEDIA_CLOCK_FREQUENCY,
149+
/* fmtpParameters= */ ImmutableMap.of(
150+
"packetization-mode", "1",
151+
"profile-level-id", "010101",
152+
"sprop-pps", "RAHA4MisDBRSQA==",
153+
"sprop-sps", "QgEBAUAAAAMAgAAAAwAAAwC0oAPAgBDlja5JG2a5cQB/FiU=",
154+
"sprop-vps", "QAEMAf//AUAAAAMAgAAAAwAAAwC0rAk="),
155+
RtpPayloadFormat.RTP_MEDIA_H265);
156+
157+
private FakeExtractorOutput extractorOutput;
158+
private RtpH265Reader rtpH265Reader;
159+
160+
@Before
161+
public void setUp() {
162+
extractorOutput = new FakeExtractorOutput();
163+
rtpH265Reader = new RtpH265Reader(H265_FORMAT);
164+
}
165+
166+
@Test
167+
public void consume_validPackets() throws ParserException {
168+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
169+
rtpH265Reader.onReceivingFirstPacket(VALID_AP_PACKET.timestamp, VALID_AP_PACKET.sequenceNumber);
170+
consume(rtpH265Reader, VALID_AP_PACKET);
171+
consume(rtpH265Reader, VALID_AP_PACKET_2);
172+
173+
FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
174+
assertThat(trackOutput.getSampleCount()).isEqualTo(2);
175+
assertThat(trackOutput.getSampleData(0))
176+
.isEqualTo(
177+
Bytes.concat(
178+
NalUnitUtil.NAL_START_CODE,
179+
NALU_1_HEADER,
180+
NALU_1_PAYLOAD,
181+
NalUnitUtil.NAL_START_CODE,
182+
NALU_2_HEADER,
183+
NALU_2_PAYLOAD));
184+
assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0);
185+
assertThat(trackOutput.getSampleData(1))
186+
.isEqualTo(
187+
Bytes.concat(
188+
NalUnitUtil.NAL_START_CODE,
189+
NALU_1_HEADER,
190+
NALU_1_PAYLOAD,
191+
NalUnitUtil.NAL_START_CODE,
192+
NALU_2_HEADER,
193+
NALU_2_PAYLOAD));
194+
assertThat(trackOutput.getSampleTimeUs(1))
195+
.isEqualTo(
196+
Util.scaleLargeTimestamp(
197+
(AP_PACKET_2_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
198+
/* multiplier= */ C.MICROS_PER_SECOND,
199+
/* divisor= */ MEDIA_CLOCK_FREQUENCY));
200+
}
201+
202+
@Test
203+
public void consume_validPacketsMixedAggregationAndSingleNalu() throws ParserException {
204+
long naluPacket1PresentationTimestampUs =
205+
Util.scaleLargeTimestamp(
206+
(SINGLE_NALU_PACKET_1_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
207+
/* multiplier= */ C.MICROS_PER_SECOND,
208+
/* divisor= */ MEDIA_CLOCK_FREQUENCY);
209+
long naluPacket2PresentationTimestampUs =
210+
Util.scaleLargeTimestamp(
211+
(SINGLE_NALU_PACKET_2_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
212+
/* multiplier= */ C.MICROS_PER_SECOND,
213+
/* divisor= */ MEDIA_CLOCK_FREQUENCY);
214+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
215+
rtpH265Reader.onReceivingFirstPacket(VALID_AP_PACKET.timestamp, VALID_AP_PACKET.sequenceNumber);
216+
consume(rtpH265Reader, VALID_AP_PACKET);
217+
consume(rtpH265Reader, SINGLE_NALU_PACKET_1);
218+
consume(rtpH265Reader, SINGLE_NALU_PACKET_2);
219+
220+
FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
221+
assertThat(trackOutput.getSampleCount()).isEqualTo(3);
222+
assertThat(trackOutput.getSampleData(0))
223+
.isEqualTo(
224+
Bytes.concat(
225+
NalUnitUtil.NAL_START_CODE,
226+
NALU_1_HEADER,
227+
NALU_1_PAYLOAD,
228+
NalUnitUtil.NAL_START_CODE,
229+
NALU_2_HEADER,
230+
NALU_2_PAYLOAD));
231+
assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0);
232+
assertThat(trackOutput.getSampleData(1))
233+
.isEqualTo(Bytes.concat(NalUnitUtil.NAL_START_CODE, NALU_1_HEADER, NALU_1_PAYLOAD));
234+
assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(naluPacket1PresentationTimestampUs);
235+
assertThat(trackOutput.getSampleData(2))
236+
.isEqualTo(Bytes.concat(NalUnitUtil.NAL_START_CODE, NALU_2_HEADER, NALU_2_PAYLOAD));
237+
assertThat(trackOutput.getSampleTimeUs(2)).isEqualTo(naluPacket2PresentationTimestampUs);
238+
}
239+
240+
@Test
241+
public void consume_invalidAggregationPacketwithExtraByte_throwsParserException() {
242+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
243+
rtpH265Reader.onReceivingFirstPacket(
244+
INVALID_AP_PACKET_EXTRA_BYTE.timestamp, INVALID_AP_PACKET_EXTRA_BYTE.sequenceNumber);
245+
assertThrows(ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_EXTRA_BYTE));
246+
}
247+
248+
@Test
249+
public void consume_invalidAggregationPacketwithMissingByte_throwsParserException() {
250+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
251+
rtpH265Reader.onReceivingFirstPacket(
252+
INVALID_AP_PACKET_MISSING_BYTE.timestamp, INVALID_AP_PACKET_MISSING_BYTE.sequenceNumber);
253+
assertThrows(
254+
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_MISSING_BYTE));
255+
}
256+
257+
@Test
258+
public void consume_invalidAggregationPacketWithInvalidNaluLength_throwsParserException() {
259+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
260+
rtpH265Reader.onReceivingFirstPacket(
261+
INVALID_AP_PACKET_INVALID_NALU_LENGTH.timestamp,
262+
INVALID_AP_PACKET_INVALID_NALU_LENGTH.sequenceNumber);
263+
assertThrows(
264+
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_INVALID_NALU_LENGTH));
265+
}
266+
267+
@Test
268+
public void consume_invalidAggregationPacketWithSingleNalu_throwsParserException() {
269+
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
270+
rtpH265Reader.onReceivingFirstPacket(
271+
INVALID_AP_PACKET_SINGLE_NALU.timestamp, INVALID_AP_PACKET_SINGLE_NALU.sequenceNumber);
272+
assertThrows(
273+
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_SINGLE_NALU));
274+
}
275+
276+
private static RtpPacket createAggregationPacket(
277+
int sequenceNumber, long timeStamp, byte[]... nalUnits) {
278+
return new RtpPacket.Builder()
279+
.setTimestamp(timeStamp)
280+
.setSequenceNumber(sequenceNumber)
281+
.setMarker(true)
282+
.setPayloadData(Bytes.concat(AP_NALU_HEADER, Bytes.concat(nalUnits)))
283+
.build();
284+
}
285+
286+
private static void consume(RtpH265Reader h265Reader, RtpPacket rtpPacket)
287+
throws ParserException {
288+
h265Reader.consume(
289+
new ParsableByteArray(rtpPacket.payloadData),
290+
rtpPacket.timestamp,
291+
rtpPacket.sequenceNumber,
292+
rtpPacket.marker);
293+
}
294+
}

0 commit comments

Comments
 (0)