什么!只靠前端实现视频分片?

简介

近期有个需求要求在瀑布流里随着滚动播放不同的视频,由于原生的视频资源只有preload参数可以简易的控制下载,为了实现更细致化的视频下载控制,需要实现视频分片加载的功能。所以,让我们来踩坑吧!

技术调研

初步方向调研

  1. html原生video标签preload策略,只有基础的metadata,auto,none三种模式,无法精准控制。
  2. HLS流媒体播放协议。HLS是苹果公司制定的标准。视频在服务器进行分段和创建索引,通过m3u8文件传递视频播放所需信息。浏览器支持更佳。
  3. DASH流媒体播放协议。DASH是由MPEG组织制定的标准。视频在服务器进行分段和创建索引,通过mpd文件传递视频播放所需信息。需利用MSE(Media Source Extension)额外开发支持(IOS不支持)。youtube、bilibili等公司都是使用这个协议。
  4. 通过HTTP-Range与MSE获取视频分片并拼接播放。不需要服务端支持。

经过探讨,在考虑到减少服务端视频资源加载的情况下(视频是存储在CDN中),决定采用第4种方式,直接由前端控制分片。

MSE介绍

Media Source Extensions API简称MSE。MSE 使我们可以把通常的单个媒体文件的 src 值替换成引用 MediaSource 对象(一个包含即将播放的媒体文件的准备状态等信息的容器),以及引用多个 SourceBuffer 对象(代表多个组成整个串流的不同媒体块)的元素。MSE 让我们能够根据内容获取的大小和频率,或是内存占用详情(例如什么时候缓存被回收),进行更加精准地控制。它是基于它可扩展的 API 建立自适应比特率流客户端(例如 DASH 或 HLS 的客户端)的基础。

基础MP4结构介绍

MP4的容器格式是由一系列的 Box 组成。Box 是容器格式的基本组成单位,每个 Box 分为两部分,一部分是 Box Header,另一部分是 Box Body。Box 是可以嵌套的。常见的第一层 Box 类型,包括 ftyp,free,moov,mdat等。 moov Box 用于存储媒体的元数据,有且仅有一个,顺序是不固定的,但一般推进放在 mdat Box 之前。在播放媒体文件时,通常需要先解析moov Box来获取媒体的基本信息,然后再根据这些信息进行媒体的解码和播放操作。

moov 后置的情况,会导致网页播放视频时,需要先下载完整个视频文件,才能开始播放。现在的浏览器都支持智能地从尾部读取 moov 数据。但我们自己分片的情况下就需要自行请求尾部的moov数据。

Fragmented MP4介绍

Fragmented MP4(FMP4)是一种优化的 MP4 文件格式,特别适用于流媒体和逐步加载的场景。它通过将媒体数据分割成多个片段(fragment),每个片段包含独立的元数据和媒体数据,从而提高了播放的灵活性和效率。 使用MSE播放分片的视频,就需要视频属于fmp4。

MSE播放普通分片视频会报错,所以我们需要引入mp4box.js对加载的视频分片进行处理

mp4box库介绍

mp4box.js是一个用于处理MP4文件的工具库,通过这个库可以解析MP4文件,提前加载moov后置的视频、处理分片数据,转化普通的Mp4分片数据为fmp4资源,以便可以边下边播。

基础分片加载流程

  1. 通过MediaSource创建链接,并绑定到video中
  2. 先请求第一块分片,分片通过MediaSource.
    • 判断第一块分片中是否包含moov(mp4box.ready事件监听)
    • 未包含moov需要再单独请求moov数据(mp4file.appendBuffer返回)
    • 当moov数据加载完后需要再回到原剩余加载位置(mp4box.seek方法获取位置)
  3. 加载剩余分片资源(本需求中只有播放过的视频资源需要进行加载)

完整加载逻辑如下图

代码参考

js 复制代码
import { Downloader } from "./downloader.js";

const url = "videoLink"
;
function loadMediaData(ctx) {
  const { dw, mp4file } = ctx.shared;
  dw.chunkStart = mp4file.seek(0, true).offset;
  mp4file.start();
  dw.resume();
}

function handleUpdateEnd(ctx) {
  console.log("upd", ctx.id, ctx.sb.updating, ctx.isEof, {...ctx});
  if (ctx.sb.updating || ctx.shared.ms.readyState !== "open") return;

  const seg = ctx.pending.shift();
  if (seg && seg.isInit) {
    ctx.shared.pendingInitCnt--;
  }
  console.log('pendingInitCnt', ctx.shared.pendingInitCnt, ctx.shared.loading)

  if (ctx.shared.pendingInitCnt === 0 && !ctx.shared.loading) {
    ctx.shared.loading = true;
    loadMediaData(ctx);
    return;
  }

  if (ctx.isEof && ctx.shared.notEndCnt) {
    ctx.shared.notEndCnt--;
  }

  if (ctx.shared.notEndCnt === 0 && !ctx.shared.isMseEnd) {
    if (ctx.sampleNum) {
      ctx.shared.mp4file.releaseUsedSamples(ctx.id, ctx.sampleNum);
      ctx.sampleNum = null;
    }

    // 检查所有 SourceBuffer 是否都完成更新
    const sourceBuffers = ctx.shared.ms.sourceBuffers;
    let allBuffersUpdated = true;
    for (let i = 0; i < sourceBuffers.length; i++) {
      if (sourceBuffers[i].updating) {
        allBuffersUpdated = false;
        break;
      }
    }
    if (allBuffersUpdated && ctx.shared.ms.readyState === 'open' && !ctx.pending?.length && !seg) {
      ctx.shared.isMseEnd = true;
      console.log('endOfStream');
      ctx.shared.ms.endOfStream();
    }
  }

  if (seg && !seg.isInit) {
    ctx.sampleNum = seg.sampleNum;
    ctx.isEof = seg.isEnd;
    console.log("appendBuffer", seg);
    ctx.sb.appendBuffer(seg.buffer);
  }
}

function linkMsAndMp4(vElem, ms, mp4file, mp4info) {
  const trackLen = mp4info.tracks.length;
  const shared = {
    ms,
    vElem,
    loading: false,
    notEndCnt: trackLen,
    pendingInitCnt: trackLen,
    dw: mp4file.dw,
    mp4file,
    loading: false,
    isMseEnd: false
  };
  mp4info.tracks.forEach(track => {
    setSegmentOptions(ms, mp4file, track, shared);
  });
}

function setSegmentOptions(ms, mp4file, track, shared) {
  const mime = `video/mp4; codecs="${track.codec}"`;
  if (!MediaSource.isTypeSupported(mime)) {
    throw new Error("MSE does not support: " + mime);
  }

  const sb = ms.addSourceBuffer(mime);
  const ctx = {
    sb,
    id: track.id,
    pending: [],
    shared
  };
  sb.addEventListener("error", e => console.error(e));
  sb.addEventListener("updateend", () => {
    console.log("updateend", { ...ctx });
    handleUpdateEnd(ctx)});
  mp4file.setSegmentOptions(track.id, ctx, { nbSamples: 100 });
}

function initializeSegmentation(mp4file) {
  mp4file.initializeSegmentation().forEach(seg => {
    const ctx = seg.user;
    console.log(seg);
    ctx.sb.appendBuffer(seg.buffer);
    ctx.pending.push({ isInit: true });
  });
}

function handleSourceOpen(evt, vElem) {
  URL.revokeObjectURL(evt.target.src);

  console.log("handleSourceOpen");
  const ms = evt.target;

  const mp4file = MP4Box.createFile();
  mp4file.onReady = info => {
    console.log("mp4file is ready: ", info);

    ms.duration = info.duration / info.timescale;

    linkMsAndMp4(vElem, ms, mp4file, info);
    initializeSegmentation(mp4file);
  };

  mp4file.onSegment = function(id, user, buffer, sampleNum, isLast) {
    console.log("onSegment", { id, user, buffer, sampleNum, isLast });
    const ctx = user;
    ctx.pending.push({
      id: id,
      buffer: buffer,
      sampleNum: sampleNum,
      isEnd: isLast
    });
    handleUpdateEnd(ctx);
  };

  mp4file.onSamples = function (id, user, samples) {
    console.log("Received "+samples.length+" samples on track "+id+" for object "+user);
  }

  const dw = (mp4file.dw = new Downloader({
    url,
    chunkSize: 180 * 1024,
    onChunk({ bytes, isEof }) {
      const next = mp4file.appendBuffer(bytes, isEof);
      console.log("onChunk player status", vElem.readyState, isEof);
      if (isEof) {
        mp4file.flush();
      } else {
        console.log('next', next)
        dw.chunkStart = next;
      }
    }
  }));

  mp4file.dw.start();

}


function attachMediaSource(vElem) {
  const ms = new MediaSource();
  ms.addEventListener("sourceopen", evt => handleSourceOpen(evt, vElem));
  vElem.src = URL.createObjectURL(ms);
  vElem.addEventListener('canplay', () => {
    console.log('canplay')
    vElem.play()
  })
}

function bootstrap() {
  if (!window.MediaSource) throw new Error("Browser does not support MSE");

  const vElem = document.querySelector("video");
  attachMediaSource(vElem);
}

document.addEventListener("DOMContentLoaded", bootstrap);

注意事项

CORS:

分片请求会触发CORS,所以需要运维支持跨域请求。且支持获取返回头的Content-Range,用于统计加载大小。

js播放/自动播放

由于浏览器限制,代码触发视频播放时必须设置muted属性。

预加载连续加载多分片后才能播放

前面部分的视频时获取HTMLMediaElement.readyState为1,连续加载多分片后,才变成4,没有中间2、3状态。而1状态无法播放。

通过查询得知跟关键帧有关,最后在调整setSegmentOptions(track.id, ctx, { nbSamples: 100 })增多关键帧解决问题。

总结

由于需求的特殊化,使用了比较hack的方式:通过前端分片并解析视频的方式实现了视频的加载控制,但是一路踩了很多坑,包括mp4box版本变更语法不同、浏览器支持等等的问题。所以在有条件的情况下,还是更建议在目前更为主流的dash或hls方案。

本文仅简单介绍了初步实现视频分片播放的逻辑与原理,还有一部分网络、下载、视频变更播放进度等功能未处理,下次我们将继续完善分片视频组件。

参考

juejin.cn/post/684490... github.com/hsiaosiyuan... www.bilibili.com/opus/147500... developer.mozilla.org/en-US/docs/... gpac.github.io/mp4box.js/ nickdesaulniers.github.io/mp4info/ juejin.cn/post/727593...

相关推荐
喝拿铁写前端5 分钟前
路由分析小工具:Vue 2 项目的路由资产一眼掌握
前端
柳鲲鹏11 分钟前
VUE3多国语言切换(国际化)
前端·javascript·vue.js
liangshanbo121511 分钟前
CSS 视觉格式化模型
前端·css
小小小小宇26 分钟前
TypeScript 中 infer 关键字
前端
__不想说话__40 分钟前
面试官问我React状态管理,我召唤了武林群侠传…
前端·react.js·面试
Cutey91641 分钟前
前端SEO优化方案
前端·javascript
webxin66642 分钟前
带鱼屏页面该怎么适配?看我的
前端
axinawang43 分钟前
SpringBoot整合Java Web三大件
java·前端·spring boot
小old弟1 小时前
🎨如何动态主题切换 —— css变量🖌️
前端
JiangJiang1 小时前
🎯 Vue 人看 useReducer:比 useState 更强的状态管理利器!
前端·react.js·面试