vue项目使用@ffmpeg/ffmpeg在客户端上传本地视频并处理的开发记录

目前实现的功能:处理本地,线上URL视频,视频加水印,分离音频、截取下载某一帧图片,调整输出分辨率,导出到VOD或本地,替换视频背景音。

浏览器版本请使用91,不要使用最新的,否则连测试都不行

不支持safari浏览器!!!视频文件在操作过程中使用blob地址

纯前端处理视频文件,由于文件是写在内存文件里面的,所以前端处理一些视频是没有问题的,项目最终结果差不多如下,只能处理小视频,上G的视频应该会很慢的而且机器内存可能满足不了需求。

1、安装包

vue项目使用@ffmpeg/ffmpeg在客户端上传本地视频并处理的开发记录_第1张图片

 2、页面上传方法功能

 vue页面

    

对应方法

 getVideo (event) {
      if (this.player) {
        this.player.dispose()
      }
      const file = event.target.files[0]
      if (file.type !== 'video/mp4') {
        alert('请选择MP4格式视频!')
        return
      }
      this.options.sources[0].src = ''
      const vm = this
      console.info(event)
      console.info(file)
      if (window.FileReader) {
        var reader = new FileReader()
        reader.readAsDataURL(file)
        // 监听文件读取结束后事件
        reader.onloadend = function (e) {
          vm.fileObj = e.target.result
          vm.options.sources[0].src = e.target.result
          vm.$nextTick(() => {
            vm.initPlay()
          })
          // console.log(e.target.result) // e.target.result就是最后的路径地址
        }
      }
    }

3、对应的ffmpeg编码加水印导出

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg'
const ffmpeg = createFFmpeg({ log: true })
 async test () {
      if (!ffmpeg.isLoaded()) {
        await ffmpeg.load()
      }
      ffmpeg.setProgress(({ ratio }) => {
        console.log(ratio)
        /*
         * ratio is a float number between 0 to 1.
         */
      })
      ffmpeg.FS('writeFile', 'overlay.png', await fetchFile('./overlay.png'))
      ffmpeg.FS('writeFile', 'a.mp4', await fetchFile(this.fileObj))
      // ffmpeg.FS('writeFile', 'a.mp4', await fetchFile('./a.mp4'))
      await ffmpeg.run(
        '-i',
        'a.mp4',
        '-i',
        'overlay.png',
        '-filter_complex',
        "[0:v][1:v] overlay=10:10:enable='between(t,0,20)'",
        'b.mp4'
      )
      console.info(ffmpeg.FS('readFile', 'b.mp4'))
      console.info(fs)
      this.offerFileAsDownload('b.mp4', ffmpeg)
      // await fs.promises.writeFile('./test.mp4', ffmpeg.FS('readFile', 'b.mp4'))
      process.exit(0)
    },

4、将上传处理好的文件上传至vod

async uploadCos (fileObj) {
      const vm = this
      try {
        const result = await this.up({
          files: [fileObj],
          dirType: 'ClientUsr',
          onprogress: (file) => {
            vm.percent = file.percent ? parseInt(file.percent * 100) : 0
          }
        })
        vm.newUrl = result.video.url
        alert('上传文件成功!' + result.video.url)
      } catch (err) {
        alert('上传文件失败' + err)
      }
    },
    async test () {
      if (!ffmpeg.isLoaded()) {
        await ffmpeg.load()
      }
      ffmpeg.setProgress(({ ratio }) => {
        console.log(ratio)
        /*
         * ratio is a float number between 0 to 1.
         */
      })
      ffmpeg.FS('writeFile', 'overlay.png', await fetchFile('./overlay.png'))
      ffmpeg.FS('writeFile', 'a.mp4', await fetchFile(this.fileObj))
      // ffmpeg.FS('writeFile', 'a.mp4', await fetchFile('./a.mp4'))
      await ffmpeg.run(
        '-i',
        'a.mp4',
        '-i',
        'overlay.png',
        '-filter_complex',
        "[0:v][1:v] overlay=10:10:enable='between(t,0,20)'",
        'b.mp4'
      )
      console.info(ffmpeg.FS('readFile', 'b.mp4'))
      // this.offerFileAsDownload('b.mp4', ffmpeg)
      const files = new window.File(
        [new Blob([ffmpeg.FS('readFile', 'b.mp4')])],
        'b.mp4',
        { type: 'video/mp4' }
      )
      this.uploadCos(files)
      // await fs.promises.writeFile('./test.mp4', ffmpeg.FS('readFile', 'b.mp4'))
      process.exit(0)
    },

5、将上传的视频解析成一帧一帧的作为视轨

ffmpeg -i 2.mp4 -r 1  -ss 0 -vframes 5 -f image2 -s 352x240 image-%02d.jpeg

 vue代码如下

 // 上传视频后解析视频帧
    async getVideoFrames () {
      this.videoInfo.waterFrames = []
      if (!ffmpeg.isLoaded()) {
        await ffmpeg.load()
      }
      // 计算10个对应的时间点
      // const averageDura = this.videoInfo.baseInfo.duration / 10
      ffmpeg.FS('writeFile', 'source.mp4', await fetchFile(this.fileObj))
      // console.info(ffmpeg.FS('readFile', 'frame.png'))
      await ffmpeg.run('-i', 'source.mp4', '-r', '1', '-ss', '0', '-vframes', '15', '-f', 'image2', '-s', '88*50', 'image-%02d.png')
      // ffmpeg -i 2.mp4 -r 1  -ss 0 -vframes 5 -f image2 -s 352x240 image-%02d.jpeg
      for (let i = 0; i < 15; i++) {
        // await ffmpeg.run('-i', 'source.mp4', '-y', '-f', '-ss', averageDura * i, '1', 'frame.png')
        let temp = i + 1
        if (temp < 10) {
          temp = '0' + temp
        }
        this.videoInfo.waterFrames.push(this.arrayBufferToBase64(ffmpeg.FS('readFile', 'image-' + temp + '.png')))
        console.info(this.videoInfo.waterFrames)
      }
    },

用的需要将uint8array图片转base64显示图

   arrayBufferToBase64 (array) {
      array = new Uint8Array(array)
      var length = array.byteLength
      var table = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H',
        'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
        'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
        'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
        'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
        'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
        'w', 'x', 'y', 'z', '0', '1', '2', '3',
        '4', '5', '6', '7', '8', '9', '+', '/']
      var base64Str = ''
      for (var i = 0; length - i >= 3; i += 3) {
        var num1 = array[i]
        var num2 = array[i + 1]
        var num3 = array[i + 2]
        base64Str += table[num1 >>> 2] +
      table[((num1 & 0b11) << 4) | (num2 >>> 4)] +
      table[((num2 & 0b1111) << 2) | (num3 >>> 6)] +
      table[num3 & 0b111111]
      }
      var lastByte = length - i
      if (lastByte === 1) {
        var lastNum1 = array[i]
        base64Str += table[lastNum1 >>> 2] + table[((lastNum1 & 0b11) << 4)] + '=='
      } else if (lastByte === 2) {
        // eslint-disable-next-line no-redeclare
        var lastNum1 = array[i]
        var lastNum2 = array[i + 1]
        base64Str += table[lastNum1 >>> 2] +
      table[((lastNum1 & 0b11) << 4) | (lastNum2 >>> 4)] +
      table[(lastNum2 & 0b1111) << 2] +
      '='
      }
      return base64Str
    },

效果图如下vue项目使用@ffmpeg/ffmpeg在客户端上传本地视频并处理的开发记录_第2张图片截取帧参考的内容

使用ffmpeg从视频中截取图像帧
1.问题
从视频中抽取图像帧,并按照指定命名规则保存。
2. 环境
centos 6.3 + ffmpeg 0.6.5
3. 方法
1)安装ffmpeg
ffmpeg 位于rpmforge中,如果你的centos没有配置rpmforge,请先配置rpmforge。
yum -y install ffmpeg
并安装对应的依赖包。
2)使用场景
1. ffmpeg -i inputfile.avi -r 1 -f image2 image-%05d.jpeg
-r 指定抽取的帧率,即从视频中每秒钟抽取图片的数量。1代表每秒抽取一帧。
-f 指定保存图片使用的格式,可忽略。
image-%05d.jpeg,指定文件的输出名字。
2. ffmpeg -i inputfile.avi -r 1 -s 4cif -f image2 image-%05d.jpeg
4cif 代表帧的尺寸为705x576.其他可用尺寸如下。
3. ffmpeg -i inputfile.avi -r 1 -t 4 -f image2 image-%05d.jpeg
-t 代表持续时间,单位为秒。
4. ffmpeg -i inputfile.avi -r 1 -ss 01:30:14 -f image2 image-%05d.jpeg
-ss 指定起始时间
5.ffmpeg -i inputfile.avi -r 1 -ss 01:30:14 -vframes 120 4cif -f image2 image-%05d.jpeg
-vframes 指定抽取的帧数


踩坑:视频缩放计算分辨率报错"width / height not divisible by 2"

出现该错误的原因是在于:视频的宽度必须是2的倍数,高度必须是2的倍数

将视频解析成一帧一帧的提高效率,不适用ffmpeg进行解析帧

参考代码

var video = document.createElement("video");

var canvas = document.getElementById("prevImgCanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

video.addEventListener('loadeddata', function() {
  reloadRandomFrame();
}, false);

video.addEventListener('seeked', function() {
  var context = canvas.getContext('2d');
  context.drawImage(video, 0, 0, canvas.width, canvas.height);
}, false);

var playSelectedFile = function(event) {
  var file = this.files[0];
  var fileURL = URL.createObjectURL(file);
  video.src = fileURL;
}

var input = document.querySelector('input');
input.addEventListener('change', playSelectedFile, false);

function reloadRandomFrame() {
  if (!isNaN(video.duration)) {
    var rand = Math.round(Math.random() * video.duration * 1000) + 1;
    video.currentTime = rand / 1000;
  }
} 



Your browser does not support the HTML5 canvas tag.

写出的vue代码如下

async extractFramesFromVideo (videoUrl) {
      const vm = this
      var video = document.createElement('video')
      var canvas = document.createElement('canvas')
      canvas.width = 88
      canvas.height = 50
      video.addEventListener('loadeddata', function () {
        for (let i = 0; i < 14; i++) {
          const temp = parseInt(video.duration / 14)
          setTimeout(() => {
            if (!isNaN(video.duration)) {
              // var rand = Math.round(Math.random() * video.duration * 1000) + 1
              video.currentTime = temp * i === 0 ? 0.1 : temp * i
            }
          }, 500 * i)
        }
      }, false)

      video.addEventListener('seeked', function () {
        var context = canvas.getContext('2d')
        context.drawImage(video, 0, 0, canvas.width, canvas.height)
        // 转换成base64形式
        const img = canvas.toDataURL('image/jpeg') // 这个就是图片的base64
        vm.videoInfo.waterFrames.push(img)
      }, false)
      video.src = videoUrl
    },

总结

该项目差不多告一段落了,优化了解析帧,视频最终上传至vod,方法里面保存了下载到本地的方法

最终代码如下

VideoCut组件






Step组件步进器






引入的common.css

/*loading样式*/
.loading{
  width: 150px;
  height: 15px;
  margin: 0 auto;
  margin-top:100px;
}
.loading span{
  display: inline-block;
  width: 15px;
  height: 100%;
  margin-right: 5px;
  border-radius: 50%;
  background: #FFFFFF;
  -webkit-animation: load 1.04s ease infinite;
}
.loading span:last-child{
  margin-right: 0px;
}
@-webkit-keyframes load{
  0%{
    opacity: 1;
  }
  100%{
    opacity: 0;
  }
}
.loading span:nth-child(1){
  -webkit-animation-delay:0.13s;
}
.loading span:nth-child(2){
  -webkit-animation-delay:0.26s;
}
.loading span:nth-child(3){
  -webkit-animation-delay:0.39s;
}
.loading span:nth-child(4){
  -webkit-animation-delay:0.52s;
}
.loading span:nth-child(5){
  -webkit-animation-delay:0.65s;
}
/*主背景*/
.video-cut{
  min-width: 1024px;
  background: linear-gradient(180deg,#003d75,#232b56);
  height: 100%;
  min-height: 700px;
  width: auto;
  min-width: 1200px;
  /*选择视频部分*/
  .select-video-box{
    margin-top: 15%;
    text-align: center;
    .title{
      color: #FFFFFF;
      font-weight: 700;
      font-size: 48px;
      margin: 0;
    }
    .tips{
      font-weight: 400;
      font-size: 18px;
      color: #FFFFFF;
      opacity: 0.8;
    }
    .select-type{
      line-height: 60px;
      width: 250px;
      margin: 30px auto;
      .select-drop{
        background: #FFFFFF;
        margin-top: 5px;
        .select-item{
          cursor: pointer;
          &:hover{
            background: #0088ff20;
          }
        }
      }
      .select-type-inner{
        background: #FFFFFF;
        .open-file{
          width: 198px;
          color: #003d75;
          border-right: 1px solid #003d75;
          cursor: pointer;
          &:hover{
            box-shadow: 0px 0px 10px #FFFFFF;
          }
        }
        .arrow{
          width: 50px;
          text-align: center;
          cursor: pointer;
          &:hover{
            box-shadow: 0px 0px 10px #FFFFFF;
          }
          i{
            color: #003d75;
            &:hover{
              color: #003d7575;
            }
          }
        }
      }
    }
  }
}
/*主要内容部分*/
.content{
  /*头部菜单部分*/
  .block-top{
    margin-bottom: 15px;
    display: flex;
    justify-content: space-between;
    .toolbar{
      display: flex;
      .tool-item{
        opacity: 0.8;
        vertical-align: middle;
        background-color: rgba(0,0,0,.1);
        color: #FFFFFF;
        font-size: 13px;
        cursor: pointer;
        line-height: 20px;
        margin-right: 5px;
        padding: 6px 15px;
        &:hover{
          opacity: 1;
        }
        &.active {
          opacity: 1;
          background: #012d56;
        }
        &:first-child{
          border-radius: 10px 0px 0px 10px;
        }
        &:last-child{
          border-radius: 0px 10px 10px 0px;
        }
        .i-name {
          margin-left: 5px;
          display: inline-block;
          cursor: pointer;
        }
        i {
          height: 20px;
        }
      }
    }
    .toolbar-right{
      display: flex;
      color: #FFFFFF;
      font-size: 13px;
      line-height: 20px;
      .reset{
        padding-right: 18px;
      }
      .tool-item{
        opacity: 0.8;
        cursor: pointer;
        &:hover{
          opacity: 1;
        }
      }
    }
  }

  .video-content{
  }
}
/*视频部分*/
.video_box{
  .video-name{
    font-size: 12px;
    text-align: center;
    color: hsla(0,0%,100%,.5);
    margin-bottom: 15px;
  }
  .video-js{
    margin: 0 auto;
  }
}
/*视频播放区域可操作的部分*/
.dragable-area {
  height: 360px;
  width: 800px;
  position: absolute;
  top: 0px;
  left: calc((100% - 800px)/2);
  .clear-water {
    position: absolute;
    right: 0;
    top: 0;
    transform: translate(50%,-50%);
    color: green;cursor: pointer;
    &:hover {
      color: darkblue;
    }
  }
}
/*展示时间部分*/
.show-time-box {
  .show-time{
    color: #00e0ff;
  }
}
/*视轨部分*/
.track-wrap{
  box-sizing: border-box;
  width: 1020px;
  overflow: visible;
  margin: 43px auto;
  .drag-frames {
    width:1000px;
    transform: translateX(10px);
    position: absolute;
    top: 0;
    left: 0;
    ul,li{
      list-style: none;
      display: block;
      margin:0;
      padding:0;
    }
    .box{
      width:1000px;
      height:50px;
      position: relative;
      /*overflow:hidden;*/
      overflow: inherit;
    }
    .left{
      width:0px;
      height:100%;
      background-color: rgba(32,57,109,.5);
      float:left;
    }

    .resize{
      transform:translateX(-10px);
      width:10px;
      height:100%;
      cursor: w-resize;
      float:left;
      background-color: #00e0ff;
      border-top-left-radius: 4px;
      border-bottom-left-radius: 4px;
      border-bottom-right-radius: 0;
      border-top-right-radius: 0;
      background-position: center;
      background-repeat: no-repeat;
      background-size: 5px auto;
      background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 3 15' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.5 3a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 15a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' fill='%23fff' clip-rule='evenodd' fill-rule='evenodd'/%3E%3C/svg%3E");
      &::before{
        content: attr(data-before);
        color: #00e0ff;
        position: absolute;
        bottom: 0;
        transform: translate(-50%, 30px);
        width: max-content;
      }
    }
    .resize:before {
    }
    .resize2{
      transform:translateX(10px);
      position:absolute;
      width:10px;
      height:100%;
      cursor: w-resize;
      float:right;
      right:0;
      background-color: #00e0ff;
      border-top-left-radius: 0px;
      border-bottom-left-radius: 0px;
      border-bottom-right-radius: 4px;
      border-top-right-radius: 4px;
      background-repeat: no-repeat;
      background-position: center;
      background-size: 5px auto;
      background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 3 15' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.5 3a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 15a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' fill='%23fff' clip-rule='evenodd' fill-rule='evenodd'/%3E%3C/svg%3E");
      &::after{
        content: attr(data-after);
        color: #00e0ff;
        position: absolute;
        bottom: 0;
        transform: translate(-50%, 30px);
        width: max-content;
      }
    }

    .right{
      float:left;
      width:0px;
      height:100%;
      background-color: rgba(32,57,109,.5);
    }
    .mid{
      min-width: 5px;
      position:relative;
      float:left;
      width:100%;
      height:100%;
      background: transparent;
    }
  }
  .components_video-navigation{
    position: relative;
  }
  .component_storyboard {
    padding: 0 10px;
    .frames {
      display: flex;
      width: 100%;
      overflow: hidden
    }
    .frame {
      img {
        width: 88px;
        height: 50px;
      }
    }
    .frame.loaded{
      background-image: none
    }
  }
  .component_storyboard .frames .frame{
    background-color: rgba(0,0,0,.8);
    background-image: url('data:image/svg+xml;utf8,');
    background-size: 25%;
    background-position: 50%;
    background-repeat: no-repeat;
    flex-shrink: 0;
    flex-grow: 0;
    pointer-events: none
  }
}
// 底部的操作菜单
.bottom-nav {
  color: #FFFFFF;
  display: flex;
  justify-content: space-between;
  .bottom-nav-item{
    &:hover{
      background-color: rgba(0,0,0,.5);
    }
    padding: 0 15px;
    cursor: pointer;
    text-align: center;
    line-height: 50px;
    height: 50px;
    border-radius: 15px;
    box-sizing: border-box;
    border: 1px solid transparent;
    position: relative;
    margin-right: 20px;
    margin-top: 10px;
    display: inline-block;
    background-color: rgba(0,0,0,.25);
  }
  .play-btn {
    width: 100px;
    i{
      font-size: 16px;
    }
  }
  .range-slide {
    width: 160px;
    padding: 8px;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%,-50%);
  }
}
// 游标的样式
.youbiao{
  position: absolute;
  left: 0px;
}
.youbiao-time{
  width: 70px;
  position: absolute;
  left: 0px;
  color:#333333;
  transform: translate(-50%,-25px);
  font-size: 12px;
  background: #FFFFFF;
  border-radius: 10px;
  padding:3px;
  text-align: center;
}
// 设置
.setting-box {
  position: absolute;
  width:fit-content;
  border-radius: 20px;
  background-color: #000;
  border: none;
  padding: 20px;
  font-size: 14px;
  transform: translate(-50%, -110%);
  div{
    width: auto;
  }
}
// 整页加载中的loading
.full-loading{
  position: absolute;
  top: 0;
  left: 0px;
  width: 100%;
  height: 100%;
  background: #33333380;
  text-align: center;
}

20210812再次优化,视频使用blob地址






你可能感兴趣的:(ffmpeg,vue,vue.js)