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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,332评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,930评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,204评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,348评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,356评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,447评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,862评论 3 394
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,516评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,710评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,518评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,582评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,295评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,848评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,881评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,121评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,737评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,280评论 2 341

推荐阅读更多精彩内容