Skip to content

RTSP H265 Aggregation packet support #2413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2 changes: 2 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
* DASH extension:
* Smooth Streaming extension:
* RTSP extension:
* Add support for RTP Aggregation Packet for H265 in accordance with RFC
7798#4.4.2 ([#2413](https://github.com/androidx/media/pull/2413)).
* Decoder extensions (FFmpeg, VP9, AV1, etc.):
* MIDI extension:
* Leanback extension:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ public void consume(ParsableByteArray data, long timestamp, int sequenceNumber,
if (payloadType >= 0 && payloadType < RTP_PACKET_TYPE_AP) {
processSingleNalUnitPacket(data);
} else if (payloadType == RTP_PACKET_TYPE_AP) {
// TODO: Support AggregationPacket mode.
throw new UnsupportedOperationException("need to implement processAggregationPacket");
processAggregationPacket(data);
} else if (payloadType == RTP_PACKET_TYPE_FU) {
processFragmentationUnitPacket(data, sequenceNumber);
} else {
Expand Down Expand Up @@ -167,6 +166,68 @@ private void processSingleNalUnitPacket(ParsableByteArray data) {
bufferFlags = getBufferFlagsFromNalType(nalHeaderType);
}

/**
* Processes Aggregation packet (RFC7798 Section 4.4.2).
*
* <p>Outputs 2 or more NAL Unit (with start code prepended) to {@link #trackOutput}. Sets {@link
* #bufferFlags} and {@link #fragmentedSampleSizeBytes} accordingly.
*/
@RequiresNonNull("trackOutput")
private void processAggregationPacket(ParsableByteArray data) throws ParserException {
// The structure of an Aggregation Packet.
// 0 1 2 3
// 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
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | PayloadHdr (Type=48) | |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
// | |
// | two or more aggregation units |
// | |
// | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | :...OPTIONAL RTP padding |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

// The structure of aggregation unit
// 0 1 2 3
// 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
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// : DONL (conditional) | NALU size |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | NALU size | |
// +-+-+-+-+-+-+-+-+ NAL unit |
// | |
// | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | :
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

// Since sprop-max-don-diff != 0 is not supported, DONL won't present in the packet.

int nalUnitsCount = 0;
data.setPosition(2); // skipping payload header (2 bytes)
while (data.bytesLeft() > 2) {
int nalUnitSize = data.readUnsignedShort(); // 2 bytes of NAL unit size
int nalHeaderType = NalUnitUtil.getH265NalUnitType(data.getData(), data.getPosition() - 3);
if (data.bytesLeft() < nalUnitSize) {
throw ParserException.createForMalformedManifest(
"Malformed Aggregation Packet. NAL unit size exceeds packet size.", /* cause= */ null);
}

fragmentedSampleSizeBytes += writeStartCode();
trackOutput.sampleData(data, nalUnitSize);
fragmentedSampleSizeBytes += nalUnitSize;
bufferFlags |= getBufferFlagsFromNalType(nalHeaderType);
nalUnitsCount++;
}
if (data.bytesLeft() > 0) {
throw ParserException.createForMalformedManifest(
"Malformed Aggregation Packet. Packet size exceeds NAL unit size.", /* cause= */ null);
}
if (nalUnitsCount < 2) {
throw ParserException.createForMalformedManifest(
"Aggregation Packet must contain at least 2 NAL units.", /* cause= */ null);
}
}

/**
* Processes Fragmentation Unit packet (RFC7798 Section 4.4.3).
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer.rtsp.reader;

import static androidx.media3.common.util.Util.getBytesFromHexString;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;

import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.ParserException;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.Util;
import androidx.media3.container.NalUnitUtil;
import androidx.media3.exoplayer.rtsp.RtpPacket;
import androidx.media3.exoplayer.rtsp.RtpPayloadFormat;
import androidx.media3.test.utils.FakeExtractorOutput;
import androidx.media3.test.utils.FakeTrackOutput;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableMap;
import com.google.common.primitives.Bytes;
import java.util.Arrays;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Unit test for {@link RtpH265Reader}. */
@RunWith(AndroidJUnit4.class)
public class RtpH265ReaderTest {

private static final long MEDIA_CLOCK_FREQUENCY = 90_000;
private static final long AP_PACKET_RTP_TIMESTAMP = 9_000_000;
private static final long AP_PACKET_2_RTP_TIMESTAMP = 9_000_040;
private static final int AP_PACKET_SEQUENCE_NUMBER = 12345;
private static final long SINGLE_NALU_PACKET_1_RTP_TIMESTAMP = 9_000_040;
private static final long SINGLE_NALU_PACKET_2_RTP_TIMESTAMP = 9_000_080;

private static final byte[] AP_NALU_HEADER = getBytesFromHexString("6001");
private static final byte[] NALU_1_LENGTH = getBytesFromHexString("000c");
private static final byte[] NALU_1_INVALID_LENGTH = getBytesFromHexString("00ff");
private static final byte[] NALU_1_HEADER = getBytesFromHexString("4001");
private static final byte[] NALU_1_PAYLOAD = getBytesFromHexString("0102030405060708090a");
private static final byte[] NALU_2_LENGTH = getBytesFromHexString("000e");
private static final byte[] NALU_2_HEADER = getBytesFromHexString("4201");
private static final byte[] NALU_2_PAYLOAD = getBytesFromHexString("1112131415161718191a1b1c");

private static final RtpPacket SINGLE_NALU_PACKET_1 =
new RtpPacket.Builder()
.setTimestamp(SINGLE_NALU_PACKET_1_RTP_TIMESTAMP)
.setSequenceNumber(AP_PACKET_SEQUENCE_NUMBER + 1)
.setMarker(true)
.setPayloadData(Bytes.concat(NALU_1_HEADER, NALU_1_PAYLOAD))
.build();

private static final RtpPacket SINGLE_NALU_PACKET_2 =
new RtpPacket.Builder()
.setTimestamp(SINGLE_NALU_PACKET_2_RTP_TIMESTAMP)
.setSequenceNumber(AP_PACKET_SEQUENCE_NUMBER + 2)
.setMarker(true)
.setPayloadData(Bytes.concat(NALU_2_HEADER, NALU_2_PAYLOAD))
.build();

private static final RtpPacket VALID_AP_PACKET =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER,
AP_PACKET_RTP_TIMESTAMP,
NALU_1_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NALU_2_LENGTH,
NALU_2_HEADER,
NALU_2_PAYLOAD);

private static final RtpPacket VALID_AP_PACKET_2 =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER + 1,
AP_PACKET_2_RTP_TIMESTAMP,
NALU_1_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NALU_2_LENGTH,
NALU_2_HEADER,
NALU_2_PAYLOAD);

private static final RtpPacket INVALID_AP_PACKET_EXTRA_BYTE =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER,
AP_PACKET_RTP_TIMESTAMP,
NALU_1_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NALU_2_LENGTH,
NALU_2_HEADER,
NALU_2_PAYLOAD,
new byte[] {0x0a});

private static final RtpPacket INVALID_AP_PACKET_MISSING_BYTE =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER,
AP_PACKET_RTP_TIMESTAMP,
NALU_1_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NALU_2_LENGTH,
NALU_2_HEADER,
Arrays.copyOf(NALU_2_PAYLOAD, NALU_2_PAYLOAD.length - 1));

private static final RtpPacket INVALID_AP_PACKET_INVALID_NALU_LENGTH =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER,
AP_PACKET_RTP_TIMESTAMP,
NALU_1_INVALID_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NALU_2_LENGTH,
NALU_2_HEADER);

private static final RtpPacket INVALID_AP_PACKET_SINGLE_NALU =
createAggregationPacket(
AP_PACKET_SEQUENCE_NUMBER,
AP_PACKET_RTP_TIMESTAMP,
NALU_1_LENGTH,
NALU_1_HEADER,
NALU_1_PAYLOAD);

private static final RtpPayloadFormat H265_FORMAT =
new RtpPayloadFormat(
new Format.Builder()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setWidth(1920)
.setHeight(1080)
.build(),
/* rtpPayloadType= */ 98,
/* clockRate= */ (int) MEDIA_CLOCK_FREQUENCY,
/* fmtpParameters= */ ImmutableMap.of(
"packetization-mode", "1",
"profile-level-id", "010101",
"sprop-pps", "RAHA4MisDBRSQA==",
"sprop-sps", "QgEBAUAAAAMAgAAAAwAAAwC0oAPAgBDlja5JG2a5cQB/FiU=",
"sprop-vps", "QAEMAf//AUAAAAMAgAAAAwAAAwC0rAk="),
RtpPayloadFormat.RTP_MEDIA_H265);

private FakeExtractorOutput extractorOutput;
private RtpH265Reader rtpH265Reader;

@Before
public void setUp() {
extractorOutput = new FakeExtractorOutput();
rtpH265Reader = new RtpH265Reader(H265_FORMAT);
}

@Test
public void consume_validPackets() throws ParserException {
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(VALID_AP_PACKET.timestamp, VALID_AP_PACKET.sequenceNumber);
consume(rtpH265Reader, VALID_AP_PACKET);
consume(rtpH265Reader, VALID_AP_PACKET_2);

FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
assertThat(trackOutput.getSampleCount()).isEqualTo(2);
assertThat(trackOutput.getSampleData(0))
.isEqualTo(
Bytes.concat(
NalUnitUtil.NAL_START_CODE,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NalUnitUtil.NAL_START_CODE,
NALU_2_HEADER,
NALU_2_PAYLOAD));
assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0);
assertThat(trackOutput.getSampleData(1))
.isEqualTo(
Bytes.concat(
NalUnitUtil.NAL_START_CODE,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NalUnitUtil.NAL_START_CODE,
NALU_2_HEADER,
NALU_2_PAYLOAD));
assertThat(trackOutput.getSampleTimeUs(1))
.isEqualTo(
Util.scaleLargeTimestamp(
(AP_PACKET_2_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
/* multiplier= */ C.MICROS_PER_SECOND,
/* divisor= */ MEDIA_CLOCK_FREQUENCY));
}

@Test
public void consume_validPacketsMixedAggregationAndSingleNalu() throws ParserException {
long naluPacket1PresentationTimestampUs =
Util.scaleLargeTimestamp(
(SINGLE_NALU_PACKET_1_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
/* multiplier= */ C.MICROS_PER_SECOND,
/* divisor= */ MEDIA_CLOCK_FREQUENCY);
long naluPacket2PresentationTimestampUs =
Util.scaleLargeTimestamp(
(SINGLE_NALU_PACKET_2_RTP_TIMESTAMP - AP_PACKET_RTP_TIMESTAMP),
/* multiplier= */ C.MICROS_PER_SECOND,
/* divisor= */ MEDIA_CLOCK_FREQUENCY);
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(VALID_AP_PACKET.timestamp, VALID_AP_PACKET.sequenceNumber);
consume(rtpH265Reader, VALID_AP_PACKET);
consume(rtpH265Reader, SINGLE_NALU_PACKET_1);
consume(rtpH265Reader, SINGLE_NALU_PACKET_2);

FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0);
assertThat(trackOutput.getSampleCount()).isEqualTo(3);
assertThat(trackOutput.getSampleData(0))
.isEqualTo(
Bytes.concat(
NalUnitUtil.NAL_START_CODE,
NALU_1_HEADER,
NALU_1_PAYLOAD,
NalUnitUtil.NAL_START_CODE,
NALU_2_HEADER,
NALU_2_PAYLOAD));
assertThat(trackOutput.getSampleTimeUs(0)).isEqualTo(0);
assertThat(trackOutput.getSampleData(1))
.isEqualTo(Bytes.concat(NalUnitUtil.NAL_START_CODE, NALU_1_HEADER, NALU_1_PAYLOAD));
assertThat(trackOutput.getSampleTimeUs(1)).isEqualTo(naluPacket1PresentationTimestampUs);
assertThat(trackOutput.getSampleData(2))
.isEqualTo(Bytes.concat(NalUnitUtil.NAL_START_CODE, NALU_2_HEADER, NALU_2_PAYLOAD));
assertThat(trackOutput.getSampleTimeUs(2)).isEqualTo(naluPacket2PresentationTimestampUs);
}

@Test
public void consume_invalidAggregationPacketwithExtraByte_throwsParserException() {
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(
INVALID_AP_PACKET_EXTRA_BYTE.timestamp, INVALID_AP_PACKET_EXTRA_BYTE.sequenceNumber);
assertThrows(ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_EXTRA_BYTE));
}

@Test
public void consume_invalidAggregationPacketwithMissingByte_throwsParserException() {
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(
INVALID_AP_PACKET_MISSING_BYTE.timestamp, INVALID_AP_PACKET_MISSING_BYTE.sequenceNumber);
assertThrows(
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_MISSING_BYTE));
}

@Test
public void consume_invalidAggregationPacketWithInvalidNaluLength_throwsParserException() {
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(
INVALID_AP_PACKET_INVALID_NALU_LENGTH.timestamp,
INVALID_AP_PACKET_INVALID_NALU_LENGTH.sequenceNumber);
assertThrows(
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_INVALID_NALU_LENGTH));
}

@Test
public void consume_invalidAggregationPacketWithSingleNalu_throwsParserException() {
rtpH265Reader.createTracks(extractorOutput, /* trackId= */ 0);
rtpH265Reader.onReceivingFirstPacket(
INVALID_AP_PACKET_SINGLE_NALU.timestamp, INVALID_AP_PACKET_SINGLE_NALU.sequenceNumber);
assertThrows(
ParserException.class, () -> consume(rtpH265Reader, INVALID_AP_PACKET_SINGLE_NALU));
}

private static RtpPacket createAggregationPacket(int sequenceNumber, long timeStamp, byte[]... nalUnits) {
return new RtpPacket.Builder()
.setTimestamp(timeStamp)
.setSequenceNumber(sequenceNumber)
.setMarker(true)
.setPayloadData(Bytes.concat(AP_NALU_HEADER, Bytes.concat(nalUnits)))
.build();
}

private static void consume(RtpH265Reader h265Reader, RtpPacket rtpPacket)
throws ParserException {
h265Reader.consume(
new ParsableByteArray(rtpPacket.payloadData),
rtpPacket.timestamp,
rtpPacket.sequenceNumber,
rtpPacket.marker);
}
}