前言
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包
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流解析流程
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
(2)推荐TS的分析工具:Elecard Stream Analyzer