目录
- MP4的“问题”
- m3u8是什么
- m3u8的好处
- 源码分析
- 扩展思考:mp4能不能像m3u8一样进行分片缓存呐?
- 资料
- 收获
一、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结构体定义
首先定义两个结构体M3U8
和M3U8Seg
,其中结构体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对一个物理文件进行逻辑切片,然后针对单独的逻辑切片(而不是物理文件空洞的方式)进行单独缓存呐? 欢迎交流
六、资料
- 视频文件M3U8和TS格式切片,讨论一下?
- m3u8 文件格式详解
- JeffVideoCache
- 头条都在用的边下边播方案
- 网易新闻从0到1的短视频性能优化之路
七、收获
通过本篇的学习时间
- 了解了MP4的“问题”(moov和mdat的顺序影响解析速度、长视频缓存整个文件为单位缓存导致命中率和复用率不够高)
- 了解M3U8是一种协议,对视频进行ts切片,可以根据不同网络切换不同切片的码率、缓存的大小可以以更小可以的切片为单位等优点
- 简单分析了JeffVideoCache对M3U8的解析和缓存支持。
感谢你的阅读
下一篇我们开始多线程并发的学习实践,欢迎关注公众号“音视频开发之旅”,一起学习成长。
欢迎交流