音视频开发之旅(51)-M3U8边缓存边播放

目录

  1. MP4的“问题”
  2. m3u8是什么
  3. m3u8的好处
  4. 源码分析
  5. 扩展思考:mp4能不能像m3u8一样进行分片缓存呐?
  6. 资料
  7. 收获

一、MP4的“问题”

我们上面两篇边缓存边播放之AndroidVideoCache和边缓存边播放之缓存分片都针对MP4格式进行缓存处理,由于很多视频都是mp4格式,所以市面上商用的或者开源的播放器和缓存项目都是只支持MP4. 但是mp4格式有两个弊端(当然也是有办法进行优化的)

1.1 moov在mdat后影响秒开率

Mp4格式是一个个Box,其中moov存储的是metadata信息,mdat存储具体音视频数据信息。如果无法解析出moov数据,是无法播放该mp4文件的。而一般情况下ffmpeg生成moov是在mdat写入完成之后的,即mdat会在moov的前面,用mediaParse来查看一个mp4视频的结构如下


这样就影响用户体验(首帧加载时长过长)。

针对这种情况,通用的做法是在服务端做处理。通过ffmpeg命令吧moov移动到mdat前面。

ffmpeg -i in.mp4 -movflags faststart out.mp4

再用mediaParse来查看一个mp4视频的结构如下


1.2 缓存分片的颗粒太大、文件空洞占用空间

上一篇我们通过文件空洞的方式进行缓存分片,虽然可以实现按块分片缓存,但是占用额外的空间(空洞也会在用)造成资源浪费。

那么有没有其他的方式来进行缓存分片呐?下面我们就开始进入今天的主题M3U8分片缓存

二、什么是m3u8

m3u8 文件是 HTTP Live Streaming(缩写为 HLS) 协议的部分内容,而 HLS 是一个由苹果公司提出的基于 HTTP 的流媒体网络传输协议。
HLS 是新一代流媒体传输协议,其基本实现原理为将一个大的媒体文件进行分片,将该分片文件资源路径记录于 m3u8 文件(即 playlist)内,其中附带一些额外描述(比如该资源的多带宽信息···)用于提供给客户端。客户端依据该 m3u8 文件即可获取对应的媒体资源,进行播放。
m3u8 文件格式详解

把mp4转为ts m3u8

 //如果视频是h264
ffmpeg -y -i 11.mp4 -vcodec copy  -vbsf h264_mp4toannexb out.ts

//如果视频是h265
ffmpeg -y -i 11.mp4 -vcodec copy  -vbsf hevc_mp4toannexb out.ts

将ts切成小的ts片

ffmpeg -i out.ts  -c copy -map 0 -f segment -segment_list ts/index.m3u8 -segment_time 15 ts/out-%04d.ts

//-f segment:切片
//-segment_list :输出切片的m3u8
//-segment_time:每个切片的时间(单位秒)

可以看到包含了一个m3u8文件和多个ts文件,其中M3U8是描述文件,ts是媒体文件。
我们先来看下M3U8文件

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:16  --> 共16个ts片
#EXTINF:15.520000,        --> 该片的时长
out-0000.ts               --> 该片的名称
#EXTINF:14.360000,
out-0001.ts
#EXTINF:15.720000,
out-0002.ts
#EXTINF:14.720000,
out-0003.ts
#EXTINF:14.440000,
out-0004.ts
#EXTINF:15.280000,
out-0005.ts
#EXTINF:15.640000,
out-0006.ts
#EXTINF:14.560000,
out-0007.ts
#EXTINF:15.040000,
out-0008.ts
#EXTINF:15.360000,
out-0009.ts
#EXTINF:14.640000,
out-0010.ts
#EXTINF:14.200000,
out-0011.ts
#EXTINF:15.160000,
out-0012.ts
#EXTINF:14.760000,
out-0013.ts
#EXTINF:15.640000,
out-0014.ts
#EXTINF:14.720000,
out-0015.ts
#EXTINF:9.960000,
out-0016.ts
#EXT-X-ENDLIST

m3u8文件是一个播放列表(playlist)索引,记录了一系列媒体片段资源,顺序播放该片段资源,即可完整展示多媒体资源。

ts是视频流文件

// ffprobe /Users/yabin/Desktop/tmp/ts/out-0001.ts

 Duration: 00:00:14.36, start: 16.960000, bitrate: 351 kb/s
  Program 1
    Metadata:
      service_name    : Service01
      service_provider: FFmpeg
    Stream #0:0[0x100]: Video: hevc (Main) (HEVC / 0x43564548), yuv420p(tv), 590x1280, 25 fps, 25 tbr, 90k tbn, 25 tbc

ts文件是一种视频切片文件,可以直接播放

对于点播来说,客户端只需按顺序下载上述片段资源,依次进行播放即可。而对于直播来说,客户端需要 定时重新请求 该 m3u8 文件,看下是否有新的片段数据需要进行下载并播放
m3u8 文件格式详解

三、m3u8的好处

通过上面小节,我们知道m3u8是一种一个协议,里面存储的是视频块的索引文件。那么它适用于什么场景呐?使用mp4还是m3u8+ts呐?

m3u8 采用切块技术,下载的播放文件 就可以少很多,只有当前播放的部分,可以更好的进行带宽控制。当然使用MP4方式下载时也是可以进行控制带宽。

对于短视频来说,由于文件比较小,直接使用mp4 从下载和播放速度以及流量上都没什么问题。
对于长视频而言, 由于moov比较大,头部解析比较耗时,缓存是以整个文件为单位的,而m3u8切片的方式保证了可以单独下载单独缓存,提高了复用率。在使用P2P技术方案时可以直接作为种源。

另外m3u8还可以 根据用户的网络带宽情况,自动为客户端匹配一个合适的码率文件进行播放,从而保证视频的流畅度。

四、源码分析

我们接续看下开源项目 JeffVideoCache 的实现。
主流程和边缓存边播放之缓存分片-物理文件空洞方案基本一致。主要的差异点在玉m3u8索引文件的解析,以及每个片单独下载逻辑。

4.1 M3U8结构体定义

首先定义两个结构体M3U8M3U8Seg,其中结构体M3U8对应的事索引文件,而M3U8Seg对应的是M3U8文件中TS文件的结构

public class M3U8 {
    private String mUrl;                 //M3U8的url
    private float mTargetDuration;       //指定的duration
    private int mSequence = 0;           //序列起始值
    private int mVersion = 3;            //版本号
    private boolean mIsLive;             //是否是直播
    private List mSegList;      //分片seg 列表
}
public class M3U8Seg  {
    private String mParentUrl;             //分片的上级M3U8的url
    private String mUrl;                   //分片的网络url
    private String mName;                  //分片的文件名
    private float mDuration;               //分片的时长
    private int mSegIndex;                 //分片索引位置,起始索引为0
    private long mFileSize;                //分片文件大小
    private long mContentLength;           //分片文件的网络请求的content-length
    private boolean mHasDiscontinuity;     //当前分片文件前是否有Discontinuity
    private boolean mHasKey;               //分片文件是否加密
    private String mMethod;                //分片文件的加密方法
    private String mKeyUrl;                //分片文件的密钥地址
    private String mKeyIv;                 //密钥IV
    private int mRetryCount;               //重试请求次数
    private boolean mHasInitSegment;       //分片前是否有#EXT-X-MAP
    private String mInitSegmentUri;        //MAP的url
    private String mSegmentByteRange;      //MAP的range
}

4.2 M3U8文件解析

根据m3u8的url从网络请求获取到对应的索引文件,然后根据m3u8协议进行解析,生成对应的M3U8和M3U8Seg对象。

public static M3U8 parseNetworkM3U8Info(String parentUrl, String videoUrl, Map headers, int retryCount) throws IOException {
        InputStreamReader inputStreamReader = null;
        BufferedReader bufferedReader = null;
        try {
            HttpURLConnection connection = HttpUtils.getConnection(videoUrl, headers);
            int responseCode = connection.getResponseCode();
            if (responseCode == HttpUtils.RESPONSE_503 && retryCount < HttpUtils.MAX_RETRY_COUNT) {
                return parseNetworkM3U8Info(parentUrl, videoUrl, headers, retryCount + 1);
            }
            bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream()));

            M3U8 m3u8 = new M3U8(videoUrl);
            int targetDuration = 0;
            int version = 0;
            int sequence = 0;
            boolean hasDiscontinuity = false;
            boolean hasEndList = false;
            boolean hasMasterList = false;
            boolean hasKey = false;
            boolean hasInitSegment = false;
            String method = null;
            String keyIv = null;
            String keyUrl = null;
            String initSegmentUri = null;
            String segmentByteRange = null;
            float segDuration = 0;
            int segIndex = 0;

            String line;
            while ((line = bufferedReader.readLine()) != null) {
                line = line.trim();
                if (TextUtils.isEmpty(line)) {
                    continue;
                }
                /**
                 * #EXTM3U
                 * #EXT-X-VERSION:3           -->Constants.TAG_VERSION
                 * #EXT-X-MEDIA-SEQUENCE:0    -->Constants.TAG_MEDIA_SEQUENCE
                 * #EXT-X-ALLOW-CACHE:YES
                 * #EXT-X-TARGETDURATION:16   -->Constants.TAG_TARGET_DURATION
                 * #EXTINF:15.520000,         -->Constants.TAG_MEDIA_DURATION
                 * out-0000.ts
                 * #EXTINF:14.360000,
                 * out-0001.ts
                 * #EXT-X-ENDLIST             --> Constants.TAG_ENDLIST
                 */
                if (line.startsWith(Constants.TAG_PREFIX)) {
                    if (line.startsWith(Constants.TAG_MEDIA_DURATION)) {
                        String ret = parseStringAttr(line, Constants.REGEX_MEDIA_DURATION);
                        if (!TextUtils.isEmpty(ret)) {
                            segDuration = Float.parseFloat(ret);
                        }
                    } else if (line.startsWith(Constants.TAG_TARGET_DURATION)) {
                        String ret = parseStringAttr(line, Constants.REGEX_TARGET_DURATION);
                        if (!TextUtils.isEmpty(ret)) {
                            targetDuration = Integer.parseInt(ret);
                        }
                    } else if (line.startsWith(Constants.TAG_VERSION)) {
                        String ret = parseStringAttr(line, Constants.REGEX_VERSION);
                        if (!TextUtils.isEmpty(ret)) {
                            version = Integer.parseInt(ret);
                        }
                    } else if (line.startsWith(Constants.TAG_MEDIA_SEQUENCE)) {
                        String ret = parseStringAttr(line, Constants.REGEX_MEDIA_SEQUENCE);
                        if (!TextUtils.isEmpty(ret)) {
                            sequence = Integer.parseInt(ret);
                        }
                    } else if (line.startsWith(Constants.TAG_STREAM_INF)) { //不一定有
                        hasMasterList = true;
                    } else if (line.startsWith(Constants.TAG_DISCONTINUITY)) { //不一定有
                        hasDiscontinuity = true;
                    } else if (line.startsWith(Constants.TAG_ENDLIST)) {
                        hasEndList = true;
                    } else if (line.startsWith(Constants.TAG_KEY)) { //不一定有
                        hasKey = true;
                        method = parseOptionalStringAttr(line, Constants.REGEX_METHOD);
                        String keyFormat = parseOptionalStringAttr(line, Constants.REGEX_KEYFORMAT);
                        if (!Constants.METHOD_NONE.equals(method)) {
                            keyIv = parseOptionalStringAttr(line, Constants.REGEX_IV);
                            if (Constants.KEYFORMAT_IDENTITY.equals(keyFormat) || keyFormat == null) {
                                if (Constants.METHOD_AES_128.equals(method)) {
                                    // The segment is fully encrypted using an identity key.
                                    String tempKeyUri = parseStringAttr(line, Constants.REGEX_URI);
                                    if (tempKeyUri != null) {
                                        keyUrl = UrlUtils.getM3U8MasterUrl(videoUrl, tempKeyUri);
                                    }
                                } else {
                                    // Do nothing. Samples are encrypted using an identity key,
                                    // but this is not supported. Hopefully, a traditional DRM
                                    // alternative is also provided.
                                }
                            } else {
                                // Do nothing.
                            }
                        }
                    } else if (line.startsWith(Constants.TAG_INIT_SEGMENT)) { //不一定有
                        String tempInitSegmentUri = parseStringAttr(line, Constants.REGEX_URI);
                        if (!TextUtils.isEmpty(tempInitSegmentUri)) {
                            hasInitSegment = true;
                            initSegmentUri = UrlUtils.getM3U8MasterUrl(videoUrl, tempInitSegmentUri);
                            segmentByteRange = parseOptionalStringAttr(line, Constants.REGEX_ATTR_BYTERANGE);
                        }
                    }
                    continue;
                }

                // It has '#EXT-X-STREAM-INF' tag;
                if (hasMasterList) {
                    String tempUrl = UrlUtils.getM3U8MasterUrl(videoUrl, line);
                    return parseNetworkM3U8Info(parentUrl, tempUrl, headers, retryCount);
                }

                if (Math.abs(segDuration) < 0.001f) {
                    continue;
                }

                M3U8Seg seg = new M3U8Seg();
                seg.setParentUrl(parentUrl);
                String tempUrl = UrlUtils.getM3U8MasterUrl(videoUrl, line);
                seg.setUrl(tempUrl);
                seg.setSegIndex(segIndex);
                seg.setDuration(segDuration);
                seg.setHasDiscontinuity(hasDiscontinuity);
                seg.setHasKey(hasKey);
                if (hasKey) {
                    seg.setMethod(method);
                    seg.setKeyIv(keyIv);
                    seg.setKeyUrl(keyUrl);
                }
                if (hasInitSegment) {
                    seg.setInitSegmentInfo(initSegmentUri, segmentByteRange);
                }
                m3u8.addSeg(seg);
                segIndex++;
                segDuration = 0;
                hasDiscontinuity = false;
                hasKey = false;
                hasInitSegment = false;
                method = null;
                keyUrl = null;
                keyIv = null;
                initSegmentUri = null;
                segmentByteRange = null;
            }

            m3u8.setTargetDuration(targetDuration);
            m3u8.setVersion(version);
            m3u8.setSequence(sequence);
            m3u8.setIsLive(!hasEndList);
            return m3u8;
        } catch (IOException e) {
            throw e;
        } finally {
            ProxyCacheUtils.close(inputStreamReader);
            ProxyCacheUtils.close(bufferedReader);
        }
    }

4.3 为了实现变下载边播放也要通过本地代理的方式

需要把M3U8Seg中的链接给替换成本地代理的地址

接下来就是进行网络请求和MP4的方式查不了太多,我们就不继续分析了。

五、扩展思考:mp4能不能像m3u8一样进行分片缓存呐?

对于长视频,由于历史原因我们使用的也是mp4方式,这样在首帧加载时长(由于moov过大)以及缓存切片(除了像上一篇讲的物理文件空洞)、带宽和流畅度控制(由于没有像m3u8支持不同码率的切换)存在一些可优化点。
对于首帧加载我们可以采用预加载的策略进行优化。
对于带宽方面我们也可以根据码率和下载进度情况进行控制。
那么缓存切片上是否可以借鉴m3u8对一个物理文件进行逻辑切片,然后针对单独的逻辑切片(而不是物理文件空洞的方式)进行单独缓存呐? 欢迎交流

六、资料

  1. 视频文件M3U8和TS格式切片,讨论一下?
  2. m3u8 文件格式详解
  3. JeffVideoCache
  4. 头条都在用的边下边播方案
  5. 网易新闻从0到1的短视频性能优化之路

七、收获

通过本篇的学习时间

  1. 了解了MP4的“问题”(moov和mdat的顺序影响解析速度、长视频缓存整个文件为单位缓存导致命中率和复用率不够高)
  2. 了解M3U8是一种协议,对视频进行ts切片,可以根据不同网络切换不同切片的码率、缓存的大小可以以更小可以的切片为单位等优点
  3. 简单分析了JeffVideoCache对M3U8的解析和缓存支持。

感谢你的阅读

下一篇我们开始多线程并发的学习实践,欢迎关注公众号“音视频开发之旅”,一起学习成长。

欢迎交流

你可能感兴趣的:(音视频开发之旅(51)-M3U8边缓存边播放)