前端大文件(视频)分片上传,后端通过ffmpeg将视频转为m3u8(多个ts文件),实现大视频在线播放

2,277 阅读4分钟

前段日子在学习react和node时,做视频上传和播放时遇到大文件上传和播放问题,在此记录一下
前端开发在日常上传较大的视频文件(如mp4,超过1g)时,使用video标签播放时通常所有视频文件流全部加载完毕时才能播放,这样用户体验是非常不好的。我们可以将其转为m3u8格式的视频文件,通过按需加载对应视频切片进行播放,无需全部加载后播放,同时可以实现视频防盗。
所需环境

  1. node
  2. ffmpeg(音视频处理工具,官网安装并配置环境变量)
  3. react
  4. fluent-ffmpeg(通过node操作ffmpeg相关命令)
  5. hls.js(播放m3u8格式视频)

以react + node(koa)为例

  1. 前端实现
获取文件的hash值(需要npm包spark-md5)用于设置服务端文件片命名
async function getFileHash(file:File){
  return new Promise((resolve,reject)=>{
    const reader = new FileReader();
    reader.readAsArrayBuffer(file);
    reader.onload=function(e){
      if(e.target){
        let buffer = e.target.result as ArrayBuffer;
        let spark = new SparkMD5.ArrayBuffer();
        let HASH;
        let suffix;
        if(buffer){
          spark.append(buffer);
          HASH = spark.end();
          suffix = file.name.substring(file.name.lastIndexOf(".")+1);
          resolve({
            buffer,
            HASH,
            suffix
          })
        }
      }
    }
  })
}
/*
  HASH:文件hash值
  index:文件序号
  file:上传切片
  name:名称
  total:文件总大小
  fn:文件上传进度
  handle:服务端处理文件进度,
  dest:文件存储目录(服务端)
  dt:视频时长
  uploadedSize:已经上传文件大小
*/
async function chunkHandle(HASH:string,index:number,file:File,name:string,
                           total:number,type:string,uploadedSize:number,dest:string,dt:number,
                           fn:(size:number)=>void,handle:(progress:number)=>void){
  try{
    fn(uploadedSize);//获取文件上传大小
    if(uploadedSize>total) {//所有切片上传完毕,文件开始合并
      let videoId="";
      let params =`?dest=${dest}&hash=${HASH}&originalname=${name}&type=${type}&total=${total}&dt=${dt}`;
      /*
        打开websocket实时获取服务端处理文件进度
      */
      let websocket = new WebSocket(`${WEBSOCKET_HOST_NAME}/video/merge${params}`);
      await socketOpen(websocket);//打开websocket
      videoId = await getSocketMsg(websocket,handle);//实时获取进度
      return await socketClose(websocket,videoId);//服务端处理完毕
    }
    let end = index*chunkSize + chunkSize;//chunkSize 每个切片大小
    if(end > total-1){
      end = total-1
    }
    let blobFile = file.slice(index*chunkSize,end);//文件切片

    let formData = new FormData();
    let fileBlob = new File([blobFile],name,{
      type:type,
    });
    formData.append("index",`${index}`);
    formData.append("hash",HASH);
    formData.append("name",name);
    formData.append("size",`${total}`);
    formData.append("type",type);
    formData.append("chunkSize",`${chunkSize}`);
    formData.append("uploadedSize",`${uploadedSize}`)
    formData.append("video",fileBlob);
    const result = await uploadVideo(formData); // 文件上传,formData设置服务端所需参数
    if(result.status === 200){
       const res:any = await chunkHandle(HASH,index+1,file,name,total,type,result.data.uploadedSize,result.data.dest,dt,fn,handle);//递归调用
       return res;
    }
  }catch (e) {
    console.log(e)
  }
}
上传工具函数封装
async function shardUtils(file:File,fn:(size:number)=>void,handle:(progress:number)=>void){
  const { name,size,type } = file;//获取文件名称,文件大小,文件类型
  let index = 0
  const fileData:any = await getFileHash(file);//获取文件上传hash值
  const dt:number = await getVideoDuration(file); //自行封装(获取文件时长)
  return await chunkHandle(fileData.HASH,index,file,name,size,type,0,"",dt,fn,handle);//开始递归分片上传
}

2. 服务端处理

使用koa-multer中间件处理
function fileStorageChunk(filePath){
  try{
    return Multer.diskStorage({
      destination: function (req, file, cb) {
        const { index,hash,name,size,type,chunkSize } = req.body;//获取上传参数
        fs.access(`${filePath}/${hash}`,(err)=>{//上传目的路径,不存在则创建
           if(err){
             fs.mkdirSync(`${filePath}/${hash}`)//创建目录
             cb(null, `${filePath}/${hash}`);//将文件存储至指定目录(这里hash为整个文件的hash值,创建一个文件名为文件hash值的文件夹)
           }else{
             cb(null, `${filePath}/${hash}`);
           }
        })
      },
      filename: function (req, file, cb) {
        const { index,hash,name,size,type,chunkSize } = req.body;
        req.body.dest = `${filePath}/${hash}`;
        cb(null, hash+`-${index}`+ path.extname(file.originalname));//文件名为文件hash值+文件切片索引
      }
    })
  }catch (e) {
    console.log(e)
  }
}
//中间件函数封装
function chunkHandle(path,uploadName,method){
  try{
    const methods=['single','array']//single为单文件上传,array为多文件上传
    if(!methods.includes(method)){
      return;
    }
    const upload = Multer({
      storage:fileStorageChunk(path), //调用fileStorageChunk
    })
    return upload[method](uploadName)
  }catch (e) {
    console.log(e)
  }
}
//视频上传
middleware
/*
  ./upload_temp/video 文件上传路径
  video 前端formData添加文件名称
  single 单个文件
*/
const videoUpload= chunkHandle("./upload_temp/video",'video','single');
直接导出videoUpload即可。
controller(在controller层返回已上传文件片信息)
async uploadVideo(ctx,next){
    try{
      const {
        chunkSize, hash, index, name, size, type, uploadedSize,
      } = ctx.req.body;
      const data = {
        chunkSize, hash, index, name, size, type,
        uploadedSize:chunkSize*1+uploadedSize*1,
        dest:ctx.req.body.dest
      }
      setResponse(ctx,"上传成功",200,data);
    }catch (e) {
      console.log(e);
      setResponse(ctx,e.message,500,{})
    }
  }
文件合并(文件分片上传后需要将其合并 并且转为m3u8)
前端合并时调用websocket,后端使用koa-websocket

//文件合并中间件
async mergeVideo(ctx,next){
    try{
      const {dest="",hash="",originalname,type,total,dt=0} = ctx.query;
      if(!isEmpty(ctx,dest,"目的路径不能为空") && !isEmpty(ctx,hash,"文件HASH值不能为空")){
        const id = new Date().getTime();
        const result = await mergeVideo2(ctx,dest,path.resolve(__dirname,"../../","./upload/video"),hash);
        if(result){
          const suffix = originalname.substring(originalname.lastIndexOf("."));
          const sourcePath = path.resolve(__dirname,"../../",`./upload/video/${hash}${suffix}`);
          const destPath   = path.resolve(__dirname,"../../",`./upload/video/`)
          const result = await videoToM3u8(sourcePath, destPath, hash,(progress)=>{
            let res={
              isProgress:true,
              percent:progress.percent
            }
            ctx.websocket.send(JSON.stringify(res));
          });//将文件转为m3u8并实时返回服务端处理文件路径
          if(result){
            await deleteFile(sourcePath);
            const vioPath = videoPath.replace("./upload","");
            const vioUrl = `${APP_HOST}:${APP_PORT}${vioPath}${hash}.m3u8`;
            const result = await uploadVideoService(ctx,id,videoPath,`${hash}.m3u8`,originalname,vioUrl);
            let res={
              id:id,
              isProgress:false,
              percent:100
            }
            ctx.websocket.send(JSON.stringify(res));
            ctx.websocket.close();
          }
        }
        /*setResponse(ctx,"文件上传成功",200,{
          id:id
        })*/
      }
    }catch (e) {
      console.log(e)
      setResponse(ctx,e.message,500);
    }
  }
function mergeVideo2(ctx,dest,source,hash){//文件合并工具函数
  return new Promise((resolve,reject)=>{
    try{
      fs.access(dest,(err)=>{
        if(!err){
          const fileList = fs.readdirSync(dest);//获取目的路径
          if(fileList.length!==0){
            fileList.sort((a,b)=>{
              let reg = /-(\d+)/;
              return reg.exec(a)[1] - reg.exec(b)[1]
            })//对每一个切片的索引进行排序
            fileList.forEach(item=>{
              let suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1];
              fs.appendFileSync(`${source}/${hash}.${suffix}`,
                fs.readFileSync(`${dest}/${item}`));//将源路径的切片合并至新目录(采用文件追加的方式)
              fs.unlinkSync(`${dest}/${item}`);
            })
            fs.rmdirSync(dest);//移除以文件切片命名的文件夹
            resolve(true);
          }
        }else{
          setResponse(ctx,err.message,500)
          reject(false);
        }
      })

    }catch (e) {
      console.log(e)
      setResponse(ctx,e.message,500)
    }
  })
}
文件转为m3u8工具函数(使用ffmpeg(安装并且配置环境变量))
function videoToM3u8(sourcePath,destPath,videoName,progressHandle){
  console.log(sourcePath)
  return new Promise((resolve,reject)=>{
    fs.access(sourcePath,(err)=>{
      if(err){
        reject(new Error("视频源路径不存在"))
      }else{
        fs.access(destPath,(err)=>{
          if(err){
            reject(new Error("目的路径不存在"))
          }else{
            ffmpeg(sourcePath).videoCodec('libx264').format('hls')
              .outputOption('-hls_list_size 0')
              .outputOption('-hls_time 30')//每隔30s视频切一片(自行设置)
              .output(path.resolve(destPath,`${videoName}.m3u8`))
              .on('progress',(progress)=>{
                progressHandle(progress)//实时进度
              })
              .on("end",()=>{
                resolve(true);
              })
              .run();
          }
        })
      }
    })
  })
}

3. 流程

Snipaste_2023-01-26_13-00-11.png
5.效果

动画.gif

如果有什么不对,大家及时指正我也是初学者,热爱前端。
项目地址www.bilibili.com/video/BV1er…
项目演示地址www.bilibili.com/video/BV1er…