TS格式解析

前言

TS 全称是 MPEG-2 Transport Stream,即MPEG-2标准中的传输流。TS流广泛用于广播电视系统,比如说数字电视,以及IPTV。广播电视系统中,TS流如果发送过来后,就会解封装和解码,然后由屏幕渲染播放。这里就有一个问题,我们看电视有很多频道,比如CCTV、地方卫视等。而同一个频道还有很多节目,就像CCTV频道下面,在同一时刻就有CCTV1-CCTV14这些节目,那么这些频道、节目、音视频码流又是如何在TS里面进行区分呢?又是如何支持随机播放呢?又是怎么完成音画同步呢?这就是TS复杂的原因。在互联网中只是借用了这种封装,只是传了一路视频和音频,比如直播,是一种比较简单的场景。

PS和TS

MPEG2-PS (Program Stream) 用来存储固定时长的视频,例如DVD

MPEG2-TS 一般用来存储实时的传送的节目。

PS 流 (Program Stream):节目流,PS 流由 PS 包组成,而一个 PS 包又由若干个 PES 包组成。一个 PS 包由具有同一时间基准的一个或多个 PES 包复合合成。

TS 流 (Transport Stream):传输流,TS 流由固定长度(一般为188 字节)的 TS 包组成,TS 包是对 PES 包的另一种封装方式,同样由具有同一时间基准的一个或多个 PES 包复合合成。PS 包是不固定长度,而 TS 包为固定长度。

(1)188 bytes:MPEG-2标准(本文会基于MPEG-2的标准去讨论ts packet)

(2)192 bytes:188 bytes + 4 bytes时间码 --> 日本DVH-S标准

(3)204 bytes:188 bytes + 16 bytes前向纠错码(FEC) --> 美国ATSC标准

(4)208 bytes:188 bytes + 4 bytes时间码 + 16 bytes前向纠错码(FEC)

TS格式构成

TS文件(码流)可以分为三层:TS层(Transport Stream)、PES层(Packet Elemental Stream)、ES层(Elementary Stream)。

ES层 :音视频数据;

PES层 : 是在音视频数据上加了时间戳等对数据帧的说明信息;

TS层:是在PES层上加入了数据流识别和传输的必要信息。

TS结构.png

PES.png

两个特殊的TS包

PAT:Program Association Table 节目关联表,每个 TS 流对应一张,用来描述该 TS 流中有多少个节目。

TS 流中中,PAT 包重复实现,大约 0.5 秒出现一个,保证实时解码性

表示 PAT 表的 TS 包 PID 值为 0,便于识别

PAT 的 payload 中传送特殊 PID 的列表,每个 PID 对应一个节目( PMT 表)

PMT:Program Map Table,节目映射表,该表的 PID 是由 PAT 表 提供给出的。

表征一路节目所有流信息。包含:

当前节目中包含的所有 Video 数据的 PID

当前节目中包含的所有 Audio 数据的 PID

与当前节目关联在一起的其他数据的 PID(如数字广播,数据通讯等使用的 PID)

TS流解析流程

解析过程.png

1、找到PAT,获取PMT的PID

通过sync_byte=0x47找到ts packet的起始位置,通过ts header中PID为0x0000找到PAT,也就是ts payload中table_id为0x00的TS包,读取PMT的PID(program_map_PID)。

2、找到PMT,获取流的PID

通过sync_byte=0x47找到ts packet的起始位置,通过ts header中PID在0x0010~0x1FFE中(不固定)确认是PMT,也就是ts payload中table_id为0x02的TS包,读取流类型(stream_type)及携带该类型流的ts packet的PID(elementary_PID)。此时就找到的音频流的PID和视频流的PID,流PID都存储于ts header中的PID字段。

3、获取音视频数据

根据ts header中的PID可以判断出ts payload携带的是音频还是视频,通过ts header中的有效载荷单元起始符(payload_unit_start_indicator),可以判断出ts packet携带的PES是否是一个PES包的第一个分包。如果是PES包的第一个分包,先要找到PES包头,提取时间戳,再跳至ES数据,这就是一帧数据的开始部分。

4、组包

ts header中的有效载荷单元起始符(payload_unit_start_indicator)为1时,就知道这是下一帧的开始了,将前面的所有ES数据组合成一帧数据。开始下一轮组帧。

相关说明:

1、ts packet是一般188 bytes,ts header是4 bytes,ts payload是0~184 bytes,adaptation field也是0~184 bytes。ts payload、adaptation field都是有可能不存在。

2、当一帧数据大于188 bytes时,会被拆分成多个ts packet存储,一般这一帧的第一个和最后一个ts packet中存在adaptation field。第一个ts packet中的adaptation field包含了时钟参考(PCR_flag=1),而最后一个ts packet中的adaptation field是为了填充ts packet使之达到188 bytes,此时的adaptation field中没有时钟参考(PCR_flag=0)。

3、封装时一个PES包封装的是一帧数据,由于一个PES包大于188 bytes,在存储时一个PES包往往存储于整数个ts packet中。但是只有第一个ts packet含有pes packet header,后面的ts packet只有es数据。并不是所有携带音视频数据的ts packet都含有pes packet header。

4、ts header中的有效载荷单元起始符(payload_unit_start_indicator)来判断,payload_unit_start_indicator=1时,就知道这是下一帧的开始了,将前面的所有ES数据组合成一帧数据。然后开始下一轮组帧。

5、一个ts文件中PAT、PMT是多组的,不仅仅只有文件开始处才有。从文件中间播放时可以找到的就近的PAT、PMT来找到音视频流。

6、TS码流由于采用了固定长度的包结构,当传输误码破坏了某一TS包的同步信息时,接收机可在固定的位置检测它后面包中的同步信息,从而恢复同步,避免了信息丢失。因此,在信道环境较为恶劣,传输误码较高时,一般采用TS码流。由于TS码流具有较强的抵抗传输误码的能力,因此目前在传输媒体中进行传输的MPEG-2码流基本上都采用了TS码流的包格式。

7、由于ts packet大小固定为188 bytes,当数据不足188 bytes时,会有调整字段,此时增加了封装数据的大小。使得文件大小变大。PAT、PMT循环插入在音视频数据中,也增加了封装数据。

8、TS流中不包含快速seek的机制,只能通过协议层实现seek。HLS协议基于TS流实现的。

如果是单独的TS,是如何实现时长统计和seek逻辑的?下面我们以EXO代码为例进行分析

代码分析:

TS时长计算:

@Override

public @ReadResult int read(ExtractorInput input, PositionHolder seekPosition)

throws IOException {

long inputLength = input.getLength();

if (tracksEnded) { //已经解析了PMT

boolean canReadDuration = inputLength != C.LENGTH_UNSET && mode != MODE_HLS;

//在读取其他数据时优先获取时长

if (canReadDuration && !durationReader.isDurationReadFinished()) {

return durationReader.readDuration(input, seekPosition, pcrPid);

}

maybeOutputSeekMap(inputLength);

}

}

public @Extractor.ReadResult int readDuration(

ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) throws IOException {

if (pcrPid <= 0) {

return finishReadDuration(input);

}

if (!isLastPcrValueRead) {

//读取最后的PCR数据

return readLastPcrValue(input, seekPositionHolder, pcrPid);

}

if (lastPcrValue == C.TIME_UNSET) {

return finishReadDuration(input);

}

if (!isFirstPcrValueRead) {

//读取第一个PCR数据

return readFirstPcrValue(input, seekPositionHolder, pcrPid);

}

if (firstPcrValue == C.TIME_UNSET) {

return finishReadDuration(input);

}

long minPcrPositionUs = pcrTimestampAdjuster.adjustTsTimestamp(firstPcrValue);

long maxPcrPositionUs = pcrTimestampAdjuster.adjustTsTimestamp(lastPcrValue);

//两个PCR数据后转换相减,得到时长

durationUs = maxPcrPositionUs - minPcrPositionUs;

if (durationUs < 0) {

Log.w(TAG, "Invalid duration: " + durationUs + ". Using TIME_UNSET instead.");

durationUs = C.TIME_UNSET;

}

return finishReadDuration(input);

}

private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid)

throws IOException {

long inputLength = input.getLength();

//timestampSearchBytes = 600 * 188 默认值

int bytesToSearch = (int) min(timestampSearchBytes, inputLength);

long searchStartPosition = inputLength - bytesToSearch;

if (input.getPosition() != searchStartPosition) {

seekPositionHolder.position = searchStartPosition;

return Extractor.RESULT_SEEK;

}

packetBuffer.reset(bytesToSearch);

input.resetPeekPosition();

input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch);

lastPcrValue = readLastPcrValueFromBuffer(packetBuffer, pcrPid);

isLastPcrValueRead = true;

return Extractor.RESULT_CONTINUE;

}

时长计算说明:

时长 = lastpcr - firstpcr。

lastpcr 必须从末尾获取,pcr不一定存在哪个位置,从末尾读取数据少了,无法获取到pcr;数据读取多了耗时较久。EXO默认读取600*188字节的数据。所以经常会解析不到时长。

此处我们可以进行优化,文件大小为length, 先从(length - 600188) 开始读取 600188 字节,如果不能获取PCR数据,则继续(length - 600188 * N)开始读取 600188 + 188字节(此处+188字节,主要考虑上次读取的位置不是以0x47开头),直到获取到PCR位置。

从实现看,视频播放至少发起三次网络请求,并且从末尾读取 600 * 188字节,所以首帧比较慢。如果是断点续播,则请求次数还要增加,启播速度更慢。

seek逻辑:

public int handlePendingSeek(ExtractorInput input, PositionHolder seekPositionHolder)

throws IOException {

while (true) {

SeekOperationParams seekOperationParams =

Assertions.checkStateNotNull(this.seekOperationParams);

long floorPosition = seekOperationParams.getFloorBytePosition();

long ceilingPosition = seekOperationParams.getCeilingBytePosition();

long searchPosition = seekOperationParams.getNextSearchBytePosition();

if (ceilingPosition - floorPosition <= minimumSearchRange) {

// The seeking range is too small, so we can just continue from the floor position.

markSeekOperationFinished(/* foundTargetFrame= */ false, floorPosition);

return seekToPosition(input, floorPosition, seekPositionHolder);

}

if (!skipInputUntilPosition(input, searchPosition)) {

return seekToPosition(input, searchPosition, seekPositionHolder);

}

input.resetPeekPosition();

TimestampSearchResult timestampSearchResult =

timestampSeeker.searchForTimestamp(input, seekOperationParams.getTargetTimePosition());

switch (timestampSearchResult.type) {

case TimestampSearchResult.TYPE_POSITION_OVERESTIMATED:

seekOperationParams.updateSeekCeiling(

timestampSearchResult.timestampToUpdate, timestampSearchResult.bytePositionToUpdate);

break;

case TimestampSearchResult.TYPE_POSITION_UNDERESTIMATED:

seekOperationParams.updateSeekFloor(

timestampSearchResult.timestampToUpdate, timestampSearchResult.bytePositionToUpdate);

break;

case TimestampSearchResult.TYPE_TARGET_TIMESTAMP_FOUND:

skipInputUntilPosition(input, timestampSearchResult.bytePositionToUpdate);

markSeekOperationFinished(

/* foundTargetFrame= */ true, timestampSearchResult.bytePositionToUpdate);

return seekToPosition(

input, timestampSearchResult.bytePositionToUpdate, seekPositionHolder);

case TimestampSearchResult.TYPE_NO_TIMESTAMP:

// We can't find any timestamp in the search range from the search position.

// Give up, and just continue reading from the last search position in this case.

markSeekOperationFinished(/* foundTargetFrame= */ false, searchPosition);

return seekToPosition(input, searchPosition, seekPositionHolder);

default:

throw new IllegalStateException("Invalid case");

}

}

}

public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp)

throws IOException {

long inputPosition = input.getPosition();

int bytesToSearch = (int) min(timestampSearchBytes, input.getLength() - inputPosition);

packetBuffer.reset(bytesToSearch);

input.peekFully(packetBuffer.getData(), /* offset= */ 0, bytesToSearch);

return searchForPcrValueInBuffer(packetBuffer, targetTimestamp, inputPosition);

}

private TimestampSearchResult searchForPcrValueInBuffer(

ParsableByteArray packetBuffer, long targetPcrTimeUs, long bufferStartOffset) {

int limit = packetBuffer.limit();

long startOfLastPacketPosition = C.INDEX_UNSET;

long endOfLastPacketPosition = C.INDEX_UNSET;

long lastPcrTimeUsInRange = C.TIME_UNSET;

while (packetBuffer.bytesLeft() >= TsExtractor.TS_PACKET_SIZE) {

int startOfPacket =

TsUtil.findSyncBytePosition(packetBuffer.getData(), packetBuffer.getPosition(), limit);

int endOfPacket = startOfPacket + TsExtractor.TS_PACKET_SIZE;

if (endOfPacket > limit) {

break;

}

long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, startOfPacket, pcrPid);

if (pcrValue != C.TIME_UNSET) {

long pcrTimeUs = pcrTimestampAdjuster.adjustTsTimestamp(pcrValue);

if (pcrTimeUs > targetPcrTimeUs) {

if (lastPcrTimeUsInRange == C.TIME_UNSET) {

// First PCR timestamp is already over target.

return TimestampSearchResult.overestimatedResult(pcrTimeUs, bufferStartOffset);

} else {

// Last PCR timestamp < target timestamp < this timestamp.

return TimestampSearchResult.targetFoundResult(

bufferStartOffset + startOfLastPacketPosition);

}

} else if (pcrTimeUs + SEEK_TOLERANCE_US > targetPcrTimeUs) {

long startOfPacketInStream = bufferStartOffset + startOfPacket;

return TimestampSearchResult.targetFoundResult(startOfPacketInStream);

}

lastPcrTimeUsInRange = pcrTimeUs;

startOfLastPacketPosition = startOfPacket;

}

packetBuffer.setPosition(endOfPacket);

endOfLastPacketPosition = endOfPacket;

}

if (lastPcrTimeUsInRange != C.TIME_UNSET) {

long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition;

return TimestampSearchResult.underestimatedResult(

lastPcrTimeUsInRange, endOfLastPacketPositionInStream);

} else {

return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT;

}

}

通过查找PCR,找到合适的seek位置(通过码率估算位置)。

此处存在两个问题

Exo查找600188,如果找不到,则不进行查找(此处可以继续读取600188,直到找到PCR数据位置)。

查找到seek位置后,并不不知道关键帧的位置。所以此处可能造成声音播放,画面不动的问题。直到遇到第一个关键帧后播放恢复正常。

参考连接:

https://blog.csdn.net/m0_60259116/article/details/125207225

https://blog.csdn.net/weixin_39399492/article/details/129019329

附:

(1)TS官方文档:http://www.telemidia.puc-rio.br/~rafaeldiniz/public_files/normas/ISO-13818/iso13818-1/ISO_IEC_13818-1_2007_PDF_version_(en).pdf

(2)推荐TS的分析工具:Elecard Stream Analyzer