视频上传和播放怎样设计?怎样截取封面,HLS 直播延迟从哪里来?
上传前可以用 object URL 把本地视频交给 video 元素,跳到某个时间点,等 seeked 事件后用 drawImage 把当前帧画到 canvas,再 toBlob 得到封面。播放 HLS 时,Safari 等支持原生 HLS 的浏览器直接设 src,其他浏览器用基于 MSE 的 hls.js。HLS 直播的延迟主要来自分片:规范要求客户端不要从离播放列表末尾不足三个目标时长的分片开始播,分片越长延迟越大;LL-HLS 用几百毫秒的部分分片和阻塞式播放列表刷新来降低延迟。
简答
比如做一个短视频发布页:用户选一段视频,前端先截一帧当封面给用户确认,再把视频分片上传;发布后的视频和直播用 HLS 播放。
截封面不需要服务端。用
URL.createObjectURL(file)把本地文件交给一个<video>,把currentTime设到想要的时间点,等seeked事件触发,说明画面已经跳过去了,这时用drawImage(video, ...)把当前帧画到 canvas 上,再toBlob导出图片。视频本身的上传和普通大文件一样,分片、断点续传。播放 HLS 时,HLS 是把视频切成几秒一段的小文件,再用
.m3u8播放列表列出来。Safari 等浏览器原生支持,canPlayType('application/vnd.apple.mpegurl')有返回值时直接设src就能播;Chrome 等不原生支持的浏览器,用 hls.js,它下载分片后通过 MSE(Media Source Extensions)把数据喂给<video>。hls.js 官方建议先判断Hls.isSupported(),因为canPlayType在 Chrome 里也可能返回maybe。直播延迟主要来自分片时长。HLS 规范要求,直播时客户端不应该从离播放列表末尾不足三个目标时长的分片开始播,所以分片 6 秒,播放器起点就落后最新画面至少十几秒。LL-HLS(低延迟 HLS)把分片再切成几百毫秒的部分分片,并让服务端挂起播放列表请求、有新内容立刻返回,Apple 文档说它能把延迟降到普通电视广播的水平。
怎样截取视频封面
drawImage 不只能画图片,也能画 <video>。HTML 规范说明,传入视频元素时,画的是调用那一刻当前播放位置的那一帧。所以截封面就是两步:让视频跳到某个时间点,再把那一帧画下来。
function captureFrame(file, time = 1) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.muted = true;
video.preload = 'auto';
video.src = url;
video.addEventListener('loadedmetadata', () => {
// 视频比 time 还短时,取中间的一帧
video.currentTime = Math.min(time, video.duration / 2);
});
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
canvas.toBlob((blob) => {
URL.revokeObjectURL(url);
blob ? resolve(blob) : reject(new Error('截图失败'));
}, 'image/jpeg', 0.85);
});
video.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('无法解码这个视频'));
});
});
}
为什么要等 seeked,不能设完 currentTime 马上画?因为跳转是异步的,设完时间以后画面还没有解码出来。MDN 说明,seeked 在跳转操作完成、当前播放位置已经改变、seeking 变回 false 时触发。HTML 规范还规定,视频的 readyState 还是 HAVE_NOTHING 或 HAVE_METADATA 时,drawImage 什么都不会画,也就是还没有当前帧的数据。
为什么不截第 0 秒?很多视频开头是黑屏或者渐入,取第 1 秒左右更像样。产品上也可以一次截几帧让用户选。
还要注意,浏览器能不能解码这个视频,取决于它支持的编码格式。用户选了一个浏览器不支持的格式,就会走到 error 分支,这时可以让服务端转码后再生成封面。
视频文件怎样上传
视频文件动辄几百 MB,上传和其他大文件一样:切成固定大小的分片并发上传,记录已完成的分片实现断点续传,用文件摘要实现秒传,见《大文件上传怎样设计分片、断点续传、秒传和并发控制?》。
上传以后,服务端通常会把视频转码成几种清晰度,并切成 HLS 分片。浏览器播放的就是这些分片,而不是用户上传的原始文件。
HLS 是怎样播放的
HLS(HTTP Live Streaming)把视频切成一段一段的小文件,再用一个文本格式的播放列表(.m3u8)按顺序列出这些分片的地址和时长。播放器先下载播放列表,再依次下载分片播放;直播时播放列表会不断追加新分片,播放器定期重新拉取。
浏览器对 HLS 的支持分两种情况。Safari 等浏览器原生支持,直接把 .m3u8 地址设给 src 就能播。其他浏览器不认识 .m3u8,就要用 JavaScript 来做:hls.js 下载播放列表和分片,然后通过 MSE 把数据交给 <video>。MDN 解释,MSE 让页面可以把 <video> 的 src 换成一个 MediaSource 对象,由 JavaScript 往它的 SourceBuffer 里追加媒体数据。hls.js 的 README 还说明,在没有 MSE 的环境里(iOS 17.1 起的 Safari),它会改用 Managed Media Source。
hls.js 官方给出的默认写法如下:
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
}
为什么先判断 hls.js,而不是先判断原生支持?README 解释,canPlayType 在 Chrome 等浏览器里可能返回 maybe,但实际原生播放某些流会失败,所以除非确实需要原生播放,推荐先用 Hls.isSupported()。MDN 也说明,canPlayType 只返回 probably、maybe 或空字符串,maybe 表示在真正尝试播放之前无法确定。
直播延迟从哪里来
直播的延迟,指主播那边发生的画面要过多久才出现在观众屏幕上。编码、上传、转码都会占一些时间,但对 HLS 来说,最大的一块来自分片。
原因有两层。第一,一个分片要完整生成以后才能写进播放列表,分片 6 秒,最新的画面至少要等这 6 秒录完才能被下载。第二,HLS 规范(RFC 8216)要求,播放列表没有结束标记时,客户端不应该从距离播放列表末尾不足三个目标时长的分片开始播放,否则容易卡顿。所以播放器一开始就会主动退后几个分片,留出缓冲。
下面按规范的这条规则,算一算播放器起播位置离最新内容有多远:
function startIndex(segments, targetDuration) {
let fromEnd = 0;
for (let i = segments.length - 1; i >= 0; i--) {
fromEnd += segments[i];
if (fromEnd >= targetDuration * 3) return i;
}
return 0;
}
function behindLiveEdge(segments, targetDuration) {
const index = startIndex(segments, targetDuration);
return segments.slice(index).reduce((sum, value) => sum + value, 0);
}
console.log(startIndex([6, 6, 6, 6, 6], 6)); // 2
console.log(behindLiveEdge([6, 6, 6, 6, 6], 6)); // 18
console.log(behindLiveEdge([2, 2, 2, 2, 2, 2, 2], 2)); // 6
播放列表里有 5 个 6 秒的分片。从倒数第三个分片(下标 2)开始,到末尾正好是 18 秒,也就是三个目标时长;再往后的分片离末尾不足 18 秒,按规范不应该从那里起播。所以播放器至少落后最新内容 18 秒,再加上最新分片本身要录满 6 秒才能发布,延迟就更大了。
把分片缩到 2 秒,同样的规则下只落后 6 秒。可是分片越短,播放列表刷新越频繁,请求数量越多,编码效率也受影响,所以普通 HLS 靠缩短分片降低延迟是有限度的。
LL-HLS 怎样降低延迟
Apple 的低延迟 HLS(LL-HLS)文档给出的思路是:在播放列表的最新位置,额外提供更小的“部分分片”(Partial Segment)。文档举的例子是,普通分片 6 秒,部分分片可能只有 200 毫秒,录好 200 毫秒就能发布,30 个部分分片加起来,正好等于随后发布的那个 6 秒完整分片。这样播放器不用等一整个分片录完。
另外几项机制减少的是等待和往返。阻塞式播放列表刷新:客户端请求播放列表时带上 _HLS_msn(想要的分片序号)等参数,服务端把请求挂起,直到包含这个分片的新版本播放列表生成才返回,省掉了轮询。预加载提示:服务端用 EXT-X-PRELOAD-HINT 提前告诉客户端下一个部分分片的地址,客户端可以提前发请求,数据一生成服务端就返回。播放列表增量更新减少每次刷新传输的数据量,Rendition Report 让切换清晰度时少走几次往返。
Apple 文档对效果的描述是,低延迟模式能把公网上的视频延迟降到普通电视广播的水平。hls.js 的 README 也列出它支持 LL-HLS 的部分分片、阻塞式播放列表刷新、增量更新和 Rendition Report。要注意,这些都需要服务端和 CDN 配合实现,前端只换播放器是不够的。
常见追问
截封面时画布被污染、toBlob 报错,是什么原因?
视频和图片一样受 CORS 限制。HTML 规范规定,视频的媒体数据是 CORS 跨域的,画进画布后画布就不再是 origin-clean,导出会抛
SecurityError。用户本地选择的文件通过 object URL 加载,不存在这个问题;截网络上的视频时,要给<video>加crossorigin,服务器也要返回 CORS 头。
MSE 和直接设置 src 有什么区别?
直接设置
src时,下载、缓冲、切换都由浏览器自己完成。MSE 把这些交给 JavaScript:页面自己决定下载哪个分片、什么时候下载、选哪种清晰度,再把数据追加进SourceBuffer。hls.js 这类播放器就是靠 MSE,在不原生支持 HLS 的浏览器里实现 HLS 播放和自适应码率。
直播要求延迟一两秒以内,HLS 还合适吗?
普通 HLS 按上面的规则会落后好几个分片,做不到。LL-HLS 用部分分片把延迟降下来,但需要服务端和 CDN 都支持。连麦这类双方实时对话的场景,一般改用 WebRTC,见《WebRTC 为什么需要信令、STUN 和 TURN?》。