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

简介

近期有个需求要求在瀑布流里随着滚动播放不同的视频,由于原生的视频资源只有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...

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax