分片下载视频的处理办法

243 阅读2分钟

如果,后端有个接口,已经实现了视频分片,前端只需要将后端的api地址交给src,然后视频组件react-player就能够帮我们发起请求,然后进行播放,这导致视频进度条不管是前进还是后退都会发起很多垃圾请求。比如:

image.png

此时,向后端发起请求的是html5的video,不是开发者,所以不管你怎么做,你都拿不到发起请求的控制权。

现在你一定想到了前端的大文件分片下载:

大文件分片下载的核心理念是:juejin.cn/post/738180… 拿到文件,然后后端通过Content-range来设置视频片段,其实具体你现在要拿哪个片段,是前端控制的,后端只需要支持range就好了

Content-range:bytes 0-102131607/102131608

结合图片的分片下载

image.png

我写出了视频加载的后端代码:

  router.get('/getFmp4', async (ctx) => {
    const stat = fs.statSync(path.join(__dirname, 'fmp4.mp4'))
    const range = ctx.req.headers.range
    const parts = range.replace(/bytes=/, '').split('-')
    const start = Number(parts[0])
    const end = Number(parts[1]) || stat.size - 1
    ctx.set('Content-Range', `bytes ${start}-${end}/${stat.size}`)
    ctx.type = 'video/mp4'
    ctx.set('Accept-Ranges', 'bytes')
    ctx.status = 206
    const stream = fs.createReadStream(path.join(__dirname, 'fmp4.mp4'), {
      start,
      end
    })
    ctx.body = stream
  })

后端接口已经写好,大视频分块加载的方式只有2种:Blob和MediaDSource。

一.用Bolb实现一个react组件:

blob表示二进制大对象,是JavaScript对不可修改二进制数据的封装类型。包含字符串的数组、ArrayBuffer、ArrayBufferViews,甚至其他Blob都可以用来创建blob。

Blob(Binary Large Object)是一种数据类型,表示一个不可变的、原始数据的类文件对象。它通常用于存储二进制数据,如图像、音频、视频文件,以及其他类似的数据。

直接上代码:

import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player';

export const VideoPlayer = ({ videoSrc, chunkSize = 10 * 1024 * 1024 }: Record<string, any>) => {
  const [videoBlob, setVideoBlob] = useState<string | null>(null);
  const [downloadProgress, setDownloadProgress] = useState(0);
  const chunksRef = useRef<ArrayBuffer[]>([]);
  const mediaSourceUrlRef = useRef<string | null>(null);

  // 每下载一块就追加到 chunks 并更新 Blob URL
  const appendChunkAndUpdateBlob = (newChunk: ArrayBuffer) => {
    chunksRef.current.push(newChunk);
    const blob = new Blob(chunksRef.current, { type: 'video/mp4' });
    const newUrl = URL.createObjectURL(blob);
    mediaSourceUrlRef.current = newUrl;
  };

  useEffect(() => {
    const downloadChunks = async () => {
      try {
        // 获取视频总大小
        const headResponse = await fetch(videoSrc, { method: 'HEAD' });
        const totalSize = parseInt(headResponse.headers.get('Content-Length') || '0', 10);

        if (!totalSize) throw new Error('无法获取视频大小');

        // 计算分片数量
        const chunkCount = Math.ceil(totalSize / chunkSize);

        // 分片下载
        for (let i = 0; i < chunkCount; i++) {
          const start = i * chunkSize;
          const end = Math.min(start + chunkSize - 1, totalSize - 1);

          const response = await fetch(videoSrc, {
            headers: { Range: `bytes=${start}-${end}` },
          });

          const arrayBuffer = await response.arrayBuffer();

          // 追加分片并更新视频源
          appendChunkAndUpdateBlob(arrayBuffer);

          // 更新进度
          setDownloadProgress(Math.round(((i + 1) / chunkCount) * 100));
        }

        // 合并分片并创建URL
        const blob = new Blob(chunksRef.current, { type: 'video/mp4' });
        const finalUrl = URL.createObjectURL(blob);
        setVideoBlob(finalUrl);
      } catch (error) {
        console.error('分片下载失败:', error);
      }
    };

    downloadChunks();

    return () => {
      if (mediaSourceUrlRef.current) {
        URL.revokeObjectURL(mediaSourceUrlRef.current);
      }
      if (videoBlob) {
        URL.revokeObjectURL(videoBlob);
      }
    };
  }, [videoSrc, chunkSize]);

  console.log(mediaSourceUrlRef.current, 98811);
  console.log(videoBlob, 9888);

  return (
    <div>
      {downloadProgress < 100 && <div>下载进度: {downloadProgress}%</div>}
      {mediaSourceUrlRef.current && (
        <ReactPlayer
          src={mediaSourceUrlRef.current}
          controls
          playing={true}
          width="100%"
          height="auto"
        />
      )}
    </div>
  );
};

他的解题思路是:我用for循环,向后端发起请求,然后将请求到的chunk放到一个数组里面接收,己收到的值我就用Blob把他们拼接起来,然后把内存地址丢给组件。

理想很丰满,现实很骨感,所有的视频肯定得有一项基础功能就是:边下载边播放。

我认为此时的mediaSourceUrlRef.current内存地址是一个定值,他会帮我实现边下载边播放。

但是实际情况是,整个视频组件一直在抖动,你根本就没有机会点击播放按钮,不过等所有的视频都下载完毕,我们的视频就能正常播放了。

为啥会抖动呢?因为你每次都在给src赋值,你每赋值一次,他就会刷新一次,如果你的网速比较快,下载快,就是连点击播放按钮的机会都没有。如果你的网速比较慢,是可以播放的,但是是都一直在晃。

看到这里你一定会说:“草泥马,这干了个啥么。” 是的,我就是这种反应。一个视频等分片下载结束以后才能播放,还不如你直接下来到本地,然后播放呢。多此一举么。

结论:Blob分片拼接的办法并不能实现视频边下载边播放的功能。

二.MediaSource(多媒体流)

MediaSource 是一个 Web API,用于在浏览器中动态生成媒体流,从而实现实时音频和视频流的播放。它允许您通过 JavaScript 代码来控制媒体数据的生成和传输,从而创建自定义的流媒体播放体验。

主要用途之一是实现流媒体的逐段加载,这对于大型视频、直播等场景非常有用。通过 MediaSource,您可以控制媒体片段的加载、缓冲和播放,从而实现更灵活的流媒体处理。

现在很多视频网站都用的 MediaSource 实现的大文件分片下载去节流的。比如:B站

    import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player';

export const VideoPlayer = ({ videoSrc, chunkSize = 10 * 1024 * 1024 }: Record<string, any>) => {
  const mediaSourceRef = useRef<MediaSource | null>(null);
  const sourceBufferRef = useRef<SourceBuffer | null>(null);
  const chunksRef = useRef<ArrayBuffer[]>([]);
  const [videoUrl, setVideoUrl] = useState<string | null>(null);
  const [downloadProgress, setDownloadProgress] = useState(0);

  // 初始化 MediaSource
  useEffect(() => {
    if (!window.MediaSource) {
      console.error('当前浏览器不支持 MediaSource');
      return;
    }

    const mediaSource = new MediaSource();
    mediaSourceRef.current = mediaSource;

    const url = URL.createObjectURL(mediaSource);
    setVideoUrl(url);

    mediaSource.addEventListener('sourceopen', handleSourceOpen);
  }, []);

  // 创建 SourceBuffer 并开始下载第一个分片
  const handleSourceOpen = () => {
    if (!mediaSourceRef.current) return;

    try {
      const sourceBuffer = mediaSourceRef.current.addSourceBuffer(
        'video/mp4; codecs="avc1.42E01E, mp4a.40.2"'
      );
      sourceBufferRef.current = sourceBuffer;

      sourceBuffer.addEventListener('updateend', downloadNextChunk);
      downloadNextChunk();
    } catch (err) {
      console.error('创建 SourceBuffer 失败:', err);
    }
  };

  // 下载并追加下一个分片
  const downloadNextChunk = async () => {
    const currentChunk = chunksRef.current.length;
    const totalSize = await getVideoTotalSize();

    if (currentChunk >= Math.ceil(totalSize / chunkSize)) {
      mediaSourceRef.current?.endOfStream();
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(start + chunkSize - 1, totalSize - 1);

    const response = await fetch(videoSrc, {
      headers: { Range: `bytes=${start}-${end}` },
    });

    const arrayBuffer = await response.arrayBuffer();

    if (sourceBufferRef.current && !sourceBufferRef.current.updating) {
      sourceBufferRef.current.appendBuffer(arrayBuffer);
      chunksRef.current.push(arrayBuffer);
      console.log(currentChunk, Math.ceil(totalSize / chunkSize), 78888);
      setDownloadProgress(
        Math.round(((currentChunk + 1) / Math.ceil(totalSize / chunkSize)) * 100)
      );
    }
  };

  // 获取视频总大小
  const getVideoTotalSize = async (): Promise<number> => {
    const headResponse = await fetch(videoSrc, { method: 'HEAD' });
    const contentLength = headResponse.headers.get('Content-Length');
    if (!contentLength) throw new Error('无法获取视频大小');
    return parseInt(contentLength, 10);
  };

  return (
    <div style={{ maxWidth: '800px', margin: '20px auto' }}>
      {downloadProgress < 100 && <div>下载进度:{downloadProgress}%</div>}
      {videoUrl && (
        <ReactPlayer src={videoUrl} controls playing={true} width="100%" height="auto" />
      )}
    </div>
  );
};

你以为大功告成了吗?想多了,控制台报错了。

image.png

你以为它会像所有的博主说的那样丝滑吗?no

MediaSource对视频类型有限制,你普通的mp4是加载不了的,他要的视频是标准 ISO BMF 格式的 MP4。

处理办法就是利用ffmpeg,把你的视频格式化一下就能播放了。

[ffmpeg 官网](https://ffmpeg.org/ffmpeg.html)

三.懒人办法

如果你不想写代码又想用Media Source实现分片下载,把下载的控制权掌握到开发者的手里,那就用知乎的这个包:https://github.com/zhihu/griffith/blob/master/packages/griffith-mp4/src/VideoComponent.tsx

推荐文章: 基于 HTTP Range 实现视频分片快速播放! (url)