音視頻開發(fā)之旅(51)-M3U8邊緩存邊播放

目錄

  1. MP4的“問題”
  2. m3u8是什么
  3. m3u8的好處
  4. 源碼分析
  5. 擴展思考:mp4能不能像m3u8一樣進(jìn)行分片緩存吶陆淀?
  6. 資料
  7. 收獲

一剿配、MP4的“問題”

我們上面兩篇邊緩存邊播放之AndroidVideoCache邊緩存邊播放之緩存分片都針對MP4格式進(jìn)行緩存處理克胳,由于很多視頻都是mp4格式,所以市面上商用的或者開源的播放器和緩存項目都是只支持MP4. 但是mp4格式有兩個弊端(當(dāng)然也是有辦法進(jìn)行優(yōu)化的)

1.1 moov在mdat后影響秒開率

Mp4格式是一個個Box溜嗜,其中moov存儲的是metadata信息,mdat存儲具體音視頻數(shù)據(jù)信息哪工。如果無法解析出moov數(shù)據(jù)赡麦,是無法播放該mp4文件的矾利。而一般情況下ffmpeg生成moov是在mdat寫入完成之后的,即mdat會在moov的前面肌幽,用mediaParse來查看一個mp4視頻的結(jié)構(gòu)如下


這樣就影響用戶體驗(首幀加載時長過長)。

針對這種情況,通用的做法是在服務(wù)端做處理款票。通過ffmpeg命令吧moov移動到mdat前面摘完。

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

再用mediaParse來查看一個mp4視頻的結(jié)構(gòu)如下


1.2 緩存分片的顆粒太大、文件空洞占用空間

上一篇我們通過文件空洞的方式進(jìn)行緩存分片,雖然可以實現(xiàn)按塊分片緩存,但是占用額外的空間(空洞也會在用)造成資源浪費。

那么有沒有其他的方式來進(jìn)行緩存分片吶祭往?下面我們就開始進(jìn)入今天的主題M3U8分片緩存

二浪读、什么是m3u8

m3u8 文件是 HTTP Live Streaming(縮寫為 HLS) 協(xié)議的部分內(nèi)容折联,而 HLS 是一個由蘋果公司提出的基于 HTTP 的流媒體網(wǎng)絡(luò)傳輸協(xié)議脸狸。
HLS 是新一代流媒體傳輸協(xié)議,其基本實現(xiàn)原理為將一個大的媒體文件進(jìn)行分片官辽,將該分片文件資源路徑記錄于 m3u8 文件(即 playlist)內(nèi)蛹磺,其中附帶一些額外描述(比如該資源的多帶寬信息···)用于提供給客戶端⊥停客戶端依據(jù)該 m3u8 文件即可獲取對應(yīng)的媒體資源萤捆,進(jìn)行播放。
m3u8 文件格式詳解

把mp4轉(zhuǎn)為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文件是一種視頻切片文件帅腌,可以直接播放

對于點播來說,客戶端只需按順序下載上述片段資源麻汰,依次進(jìn)行播放即可狞膘。而對于直播來說,客戶端需要 定時重新請求 該 m3u8 文件,看下是否有新的片段數(shù)據(jù)需要進(jìn)行下載并播放
m3u8 文件格式詳解

三、m3u8的好處

通過上面小節(jié)父能,我們知道m(xù)3u8是一種一個協(xié)議盼理,里面存儲的是視頻塊的索引文件。那么它適用于什么場景吶智亮?使用mp4還是m3u8+ts吶忆某?

m3u8 采用切塊技術(shù),下載的播放文件 就可以少很多阔蛉,只有當(dāng)前播放的部分弃舒,可以更好的進(jìn)行帶寬控制。當(dāng)然使用MP4方式下載時也是可以進(jìn)行控制帶寬。

對于短視頻來說聋呢,由于文件比較小苗踪,直接使用mp4 從下載和播放速度以及流量上都沒什么問題。
對于長視頻而言削锰, 由于moov比較大通铲,頭部解析比較耗時,緩存是以整個文件為單位的器贩,而m3u8切片的方式保證了可以單獨下載單獨緩存颅夺,提高了復(fù)用率。在使用P2P技術(shù)方案時可以直接作為種源蛹稍。

另外m3u8還可以 根據(jù)用戶的網(wǎng)絡(luò)帶寬情況吧黄,自動為客戶端匹配一個合適的碼率文件進(jìn)行播放,從而保證視頻的流暢度唆姐。

四拗慨、源碼分析

我們接續(xù)看下開源項目 JeffVideoCache 的實現(xiàn)。
主流程和邊緩存邊播放之緩存分片-物理文件空洞方案基本一致厦酬。主要的差異點在玉m3u8索引文件的解析胆描,以及每個片單獨下載邏輯。

4.1 M3U8結(jié)構(gòu)體定義

首先定義兩個結(jié)構(gòu)體M3U8M3U8Seg仗阅,其中結(jié)構(gòu)體M3U8對應(yīng)的事索引文件昌讲,而M3U8Seg對應(yīng)的是M3U8文件中TS文件的結(jié)構(gòu)

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<M3U8Seg> mSegList;      //分片seg 列表
}
public class M3U8Seg  {
    private String mParentUrl;             //分片的上級M3U8的url
    private String mUrl;                   //分片的網(wǎng)絡(luò)url
    private String mName;                  //分片的文件名
    private float mDuration;               //分片的時長
    private int mSegIndex;                 //分片索引位置,起始索引為0
    private long mFileSize;                //分片文件大小
    private long mContentLength;           //分片文件的網(wǎng)絡(luò)請求的content-length
    private boolean mHasDiscontinuity;     //當(dāng)前分片文件前是否有Discontinuity
    private boolean mHasKey;               //分片文件是否加密
    private String mMethod;                //分片文件的加密方法
    private String mKeyUrl;                //分片文件的密鑰地址
    private String mKeyIv;                 //密鑰IV
    private int mRetryCount;               //重試請求次數(shù)
    private boolean mHasInitSegment;       //分片前是否有#EXT-X-MAP
    private String mInitSegmentUri;        //MAP的url
    private String mSegmentByteRange;      //MAP的range
}

4.2 M3U8文件解析

根據(jù)m3u8的url從網(wǎng)絡(luò)請求獲取到對應(yīng)的索引文件减噪,然后根據(jù)m3u8協(xié)議進(jìn)行解析短绸,生成對應(yīng)的M3U8和M3U8Seg對象。

public static M3U8 parseNetworkM3U8Info(String parentUrl, String videoUrl, Map<String, String> 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 為了實現(xiàn)變下載邊播放也要通過本地代理的方式

需要把M3U8Seg中的鏈接給替換成本地代理的地址

接下來就是進(jìn)行網(wǎng)絡(luò)請求和MP4的方式查不了太多筹裕,我們就不繼續(xù)分析了醋闭。

五、擴展思考:mp4能不能像m3u8一樣進(jìn)行分片緩存吶朝卒?

對于長視頻证逻,由于歷史原因我們使用的也是mp4方式,這樣在首幀加載時長(由于moov過大)以及緩存切片(除了像上一篇講的物理文件空洞)抗斤、帶寬和流暢度控制(由于沒有像m3u8支持不同碼率的切換)存在一些可優(yōu)化點囚企。
對于首幀加載我們可以采用預(yù)加載的策略進(jìn)行優(yōu)化。
對于帶寬方面我們也可以根據(jù)碼率和下載進(jìn)度情況進(jìn)行控制瑞眼。
那么緩存切片上是否可以借鑒m3u8對一個物理文件進(jìn)行邏輯切片龙宏,然后針對單獨的邏輯切片(而不是物理文件空洞的方式)進(jìn)行單獨緩存吶? 歡迎交流

六伤疙、資料

  1. 視頻文件M3U8和TS格式切片银酗,討論一下?
  2. m3u8 文件格式詳解
  3. JeffVideoCache
  4. 頭條都在用的邊下邊播方案
  5. 網(wǎng)易新聞從0到1的短視頻性能優(yōu)化之路

七、收獲

通過本篇的學(xué)習(xí)時間

  1. 了解了MP4的“問題”(moov和mdat的順序影響解析速度黍特、長視頻緩存整個文件為單位緩存導(dǎo)致命中率和復(fù)用率不夠高)
  2. 了解M3U8是一種協(xié)議蛙讥,對視頻進(jìn)行ts切片,可以根據(jù)不同網(wǎng)絡(luò)切換不同切片的碼率衅澈、緩存的大小可以以更小可以的切片為單位等優(yōu)點
  3. 簡單分析了JeffVideoCache對M3U8的解析和緩存支持键菱。

感謝你的閱讀

下一篇我們開始多線程并發(fā)的學(xué)習(xí)實踐,歡迎關(guān)注公眾號“音視頻開發(fā)之旅”今布,一起學(xué)習(xí)成長经备。

歡迎交流

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市部默,隨后出現(xiàn)的幾起案子侵蒙,更是在濱河造成了極大的恐慌,老刑警劉巖傅蹂,帶你破解...
    沈念sama閱讀 219,539評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件纷闺,死亡現(xiàn)場離奇詭異,居然都是意外死亡份蝴,警方通過查閱死者的電腦和手機犁功,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評論 3 396
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來婚夫,“玉大人浸卦,你說我怎么就攤上這事“覆冢” “怎么了限嫌?”我有些...
    開封第一講書人閱讀 165,871評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長时捌。 經(jīng)常有香客問我怒医,道長,這世上最難降的妖魔是什么奢讨? 我笑而不...
    開封第一講書人閱讀 58,963評論 1 295
  • 正文 為了忘掉前任稚叹,我火速辦了婚禮,結(jié)果婚禮上拿诸,老公的妹妹穿的比我還像新娘入录。我一直安慰自己,他們只是感情好佳镜,可當(dāng)我...
    茶點故事閱讀 67,984評論 6 393
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著凡桥,像睡著了一般蟀伸。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,763評論 1 307
  • 那天啊掏,我揣著相機與錄音蠢络,去河邊找鬼。 笑死迟蜜,一個胖子當(dāng)著我的面吹牛刹孔,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播娜睛,決...
    沈念sama閱讀 40,468評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼髓霞,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了畦戒?” 一聲冷哼從身側(cè)響起方库,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎障斋,沒想到半個月后纵潦,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,850評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡垃环,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,002評論 3 338
  • 正文 我和宋清朗相戀三年邀层,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片遂庄。...
    茶點故事閱讀 40,144評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡寥院,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出涧团,到底是詐尸還是另有隱情只磷,我是刑警寧澤,帶...
    沈念sama閱讀 35,823評論 5 346
  • 正文 年R本政府宣布泌绣,位于F島的核電站钮追,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏阿迈。R本人自食惡果不足惜元媚,卻給世界環(huán)境...
    茶點故事閱讀 41,483評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望苗沧。 院中可真熱鬧刊棕,春花似錦、人聲如沸待逞。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽识樱。三九已至嗤无,卻和暖如春震束,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背当犯。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評論 1 272
  • 我被黑心中介騙來泰國打工垢村, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人嚎卫。 一個月前我還...
    沈念sama閱讀 48,415評論 3 373
  • 正文 我出身青樓嘉栓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親拓诸。 傳聞我的和親對象是個殘疾皇子侵佃,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,092評論 2 355

推薦閱讀更多精彩內(nèi)容