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

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

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

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

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

1、安装包

 2、页面上传方法功能

 vue页面

    <input v-show="false"  id="pop_video" type="file" accept="video/mp4" capture="camcorder" @change="getVideo" name="fileTrans" ref="file" value="">

对应方法

 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
    },

效果图如下截取帧参考的内容

使用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;
  }
} 

<input type="file" accept="video/*" />
<input type="submit" onClick="reloadRandomFrame()" value="load random frame" /><br/>
<canvas id="prevImgCanvas">Your browser does not support the HTML5 canvas tag.</canvas> 

写出的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组件

<template>
  <div class="video-cut" crossorigin=“anonymous”>
    <!--    <span v-tip="123">2222</span>
        <i class="iconfont icon-lkingboxiaojianzi"></i>-->
    <template v-if="!options.sources[0].src">
      <input v-show="false" id="pop_video" type="file" accept="video/mp4" @change="getVideo" name="fileTrans" ref="file"
             value="">
    </template>
    <input v-show="false" id="pop_pic" type="file" accept="image/*" @change="getPic" name="filePic" ref="filePic"
           value="">
    <div style="padding: 30px 40px;">
      <div class="content" v-if="options.sources[0].src">
        <!--头部菜单-->
        <div class="block-top">
          <div class="toolbar">
            <div class="tool-item" @click="changeCurrentType('cut')" :class="{'active': currentType == 'cut'}" v-tip.top.dark.hover="'裁剪视频'"><i class="iconfont icon-cut"></i><span
              class="i-name">裁剪</span></div>
            <div class="tool-item" @click="changeCurrentType('snapshot')" :class="{'active': currentType == 'snapshot'}" v-tip.top.dark.hover="'屏幕快照'"><i class="iconfont icon-post_pic"></i></div>
            <div class="tool-item" @click="changeCurrentType('addimg')" :class="{'active': currentType == 'addimg'}" v-tip.top.dark.hover="'上传水印图片'"><i
              class="iconfont icon-ai-up-img"></i></div>
            <div class="tool-item" @click="changeCurrentType('audio')" :class="{'active': currentType == 'audio'}" v-tip.top.dark.hover="'音频'"><i
              class="iconfont icon-yinpinbofang"></i></div>
          </div>
          <div class="toolbar-right">
            <div class="reset tool-item" v-tip.top.dark.hover="'回到初始状态'" @click="reset">
              <i class="iconfont icon-chexiao" style="margin-right: 5px;"></i>重置
            </div>
            <div class="tool-item" v-tip.top.dark.hover="'关闭项目'" @click="options.sources[0].src = ''"><i class="iconfont icon-guanbi111"></i></div>
          </div>
        </div>
        <!--视频部分-->
        <div class="video-content">
          <div class="video_box">
            <div class="video-name">{
  
  {videoInfo.baseInfo.name}}</div>
            <div style="position: relative">
              <video ref="videoPlayer" id="videoPlayer" class="video-js"></video>
              <!--因为视频大小是动态的,所以dragable-area的大小也是不断变化的-->
              <div class="dragable-area" :style="{'width': videoInfo.baseInfo.width + 'px', 'left': 'calc((100% - '+videoInfo.baseInfo.width+'px)/2)'}">
                <!--判断是不是需要展示可编辑水印部分内容-->
                <template>
                  <vue-draggable-resizable
                    v-if="waterPicInfo.source"
                    :draggable="currentType === 'addimg'"
                    :resizable="currentType === 'addimg'"
                    style="position: absolute"
                    :w="waterPicWidth"
                    :h="50"
                    :x="10"
                    :y="10"
                    :min-width="50"
                    :min-height="50"
                    :grid="[10,10]"
                    :parent="true"
                    @dragging="onDrag"
                    @resizing="onResize"
                  >
                    <i v-if="currentType === 'addimg'" class="iconfont icon-guanbi111 clear-water"
                       @click="waterPicInfo={source: null,x: 10,y: 10,width: null,height: 50}"></i>
                    <img :src="waterPicInfo.source" style="width: 100%;height: 100%" :style="{'opacity':waterPicInfo.opacity?waterPicInfo.opacity/100:1}"/>
                  </vue-draggable-resizable>
                </template>
                <div ></div>
              </div>
            </div>
          </div>
        </div>
        <!--底部变化的菜单-->
        <div>
          <!--轨道区域-->
          <div class="track-wrap">
            <div  class="components_video-navigation navigation">
              <div  class="component_storyboard storyboard">
                <!--视频的帧数组-->
                <div  class="frames">
                  <div class="frame loaded" style="width: 80px; height: 50px;" v-for="(item, index) in 13" :key="index">
                    <img :src="videoInfo.waterFrames[index]" v-if="videoInfo.waterFrames[index]"
                         class="loaded"/>
                  </div>
                </div>
              </div>
              <div class="drag-frames">
                <ul class="box" ref="box">
                  <li class="left" ref="left"></li>
                  <li class="mid" ref="mid">
                    <div class="resize" ref="resize"></div>
                    <div class="youbiao" ref="youbiao" :style="{'left':youbiaoLeft}" style="height: 50px;border-left: 1px solid #FFFFFF"></div>
                    <div class="youbiao-time" ref="youbiaotime" :style="{'left':youbiaoLeft}"  v-if="player">{
  
  { transTimeCom }}</div>
                    <div class="resize2" ref="resize2"></div>
                  </li>
                  <li class="right" ref="right"></li>
                </ul>
              </div>
            </div>
          </div>
          <!--底部操作菜单-->
          <div class="bottom-nav">
            <div style="display: flex">
              <!--控制视频播放的菜单-->
              <div class="play-btn bottom-nav-item" @click="!playStatus?player.play():player.pause()">
                <i class="iconfont icon-bofang" v-if="!playStatus"></i>
                <i class="iconfont icon-weibiaoti519" v-else></i>
              </div>
              <!--cut视频的部分-->
              <template v-if="currentType === 'cut'">
                <div class="bottom-nav-item" style="padding: 0 20px;display: flex">
                  <step @addSecond="addSecond" :type="'start'" @delSecond="delSecond" :initval="videoInfo.controlInfo.start" style="margin-right: 10px;" :min="0" :max="videoInfo.controlInfo.end"></step>
                  <step @addSecond="addSecond" :type="'end'" @delSecond="delSecond" :initval="videoInfo.controlInfo.end" :min="videoInfo.controlInfo.start" :max="videoInfo.baseInfo.duration"></step>
                </div>
              </template>
              <!--屏幕快照部分-->
              <template v-if="currentType === 'snapshot'">
                <div class="bottom-nav-item"  @click="makePhoto">
                  <i class="iconfont icon-post_pic" style="margin-right: 10px"></i>  下载快照
                </div>
              </template>
              <!--添加水印图片部分-->
              <template v-if="currentType === 'addimg'">
                <div class="bottom-nav-item" @click="selectPic">
                  <i class="iconfont icon-ai-up-img" style="margin-right: 10px"></i>  添加图片
                </div>
                <div class="bottom-nav-item">
                  <label style="display: inline-block">透明度</label>
                  <div style="position: relative;display: inline-block;min-width: 160px;transform: translateY(-5px)">
                    <vue-range-slider width="160" class="range-slide" ref="slider" v-model="waterPicInfo.opacity"></vue-range-slider>
                  </div>
                </div>
              </template>
              <!--视频声音相关-->
              <template v-if="currentType === 'audio'">
                <div class="bottom-nav-item"  @click="downloadAudio" v-tip.top.dark.hover="'下载视频的音频'">
                  <i class="iconfont icon-xiazai" style="margin-right: 10px"></i>  下载音频
                </div>
              </template>
            </div>
            <!--靠右的设置、保存输出-->
            <template v-if="videoInfo.baseInfo.originHeight">
              <div style="float: right">
                <!--输出选项进行设置-->
                <div class="setting-box" v-if="showSetting" @mouseleave="showSetting=false">
                  <div v-for="key in Object.keys(resolvingPower)" :key="key">
                    <template v-if="!key || key <= videoInfo.baseInfo.originHeight">
                      <label>
                        <input type="radio"  name ="rdSpeed" :value="key" v-model="videoInfo.controlInfo.resolvingPower">
                        {
  
  {resolvingPower[key]}}
                      </label>
                    </template>
                  </div>
                </div>
                <div class="bottom-nav-item"  v-tip.top.dark.hover="'输出设置'" @click="showSetting = !showSetting">
                  <i class="iconfont icon-shezhi"></i>
                </div>
                <div class="bottom-nav-item"  v-tip.top.dark.hover="'保存'" @click="saveFinally">
                  保存
                </div>
              </div>
            </template>
          </div>
        </div>
      </div>
      <!--选择视频-->
      <div class="select-video-box" v-else>
        <div class="title">简易剪辑视频</div>
        <div class="tips" @click="initFfmpeg"> Trim or cut video</div>
        <div class="select-type" v-if="initFfmpegBool">
          <div class="select-type-inner">
            <div style="display: inline-block" class="open-file" @click="selectFile">打开文件</div>
            <div style="display: inline-block" class="arrow"
                 @click="showSelectDrop?showSelectDrop=false:showSelectDrop=true"><i class="iconfont icon-xia"></i></div>
          </div>
          <div class="select-drop" v-if="showSelectDrop" @mouseleave="showSelectDrop=false">
            <div class="select-item">本地视频</div>
            <div class="select-item">平台视频</div>
            <div class="select-item" @click="inputFileUrl">通过视频URL</div>
          </div>
        </div>
        <div v-else style="color: white">
          <div class="loading">
            <span></span>
            <span></span>
            <span></span>
            <span></span>
            <span></span>
          </div>
          <div style="margin-top: 20px;">环境准备中...</div>
        </div>
      </div>
    </div>
    <!--加载视频中,请稍等的过程-->
    <div class="full-loading" v-show="showFullLoading">
      <div style="color: white;position: absolute;top: 50%;left: 50%;transform: translate(-50%,-50%)">
        <div class="loading" style="margin-top: 0px;">
          <span></span>
          <span></span>
          <span></span>
          <span></span>
          <span></span>
        </div>
        <div style="margin-top: 20px;">{
  
  {showFullLoadingText}}</div>
      </div>
    </div>
  </div>
</template>

<script>
import moment from 'moment'
import { mapActions } from 'vuex'
// import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg'
import step from './Step'
import 'vue-range-component/dist/vue-range-slider.css'
import VueRangeSlider from 'vue-range-component'
// const ffmpeg = createFFmpeg({ log: true })
// eslint-disable-next-line no-undef
const { createFFmpeg, fetchFile } = FFmpeg
const ffmpeg = createFFmpeg({
  corePath: 'https://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js'
})
export default {
  name: 'Home',
  components: { step, VueRangeSlider },
  props: ['videoinfo_parent'],
  data () {
    return {
      maxSize: 1024 * 1024 * 1024,
      showFullLoading: false,
      showFullLoadingText: '',
      showSetting: false,
      resolvingPower: {
        360: '360P', // '640:-1': '360P'
        480: '480P', // '720:-1': '480P',
        720: '720P', // '1280:-1'
        1080: '1080P', // '1920:-1'
        '': '不设置'
      },
      playStatus: false,
      currentType: 'cut', // 当前默认操作项,cut 裁剪,addimg 添加水印图片,snapshot 屏幕快照,crop 裁剪划选区,audio视频声音
      initFfmpegBool: false, // 是否已经初始化ffmpeg环境
      videoInfo: { // 操作视频的基本信息
        baseInfo: {
          name: '新编辑视频',
          resolvingPower: null, // 原始视频的分辨率
          width: null,
          height: 360,
          duration: 0,
          originWidth: null, // 原视频的宽
          originHeight: null // 原视频的高
        },
        currentTime: 0,
        controlInfo: {
          start: 0, // 开始时间
          end: null, // 结束时间
          resolvingPower: null // 输出的码率
        },
        waterFrames: [] // 获得对应帧数的图片数组,保留10张图片
      },
      waterPicWidth: 50,
      waterPicInfo: { // 水印图片的相关信息
        source: null,
        x: 10,
        y: 10,
        width: null,
        height: 50,
        opacity: 100
      },
      percent: 0,
      showSelectDrop: false,
      fileObj: null,
      player: null,
      options: {
        controls: false, // 是否显示底部控制栏
        preload: 'auto', // 加载<video>标签后是否加载视频
        autoplay: false, // 静音播放
        // playbackRates: [0.5, 1, 1.5, 2],// 倍速播放
        width: '800px',
        height: '360px',
        controlBar: {
          // 自定义按钮的位置
          children: []
        },
        sources: [
          {
            src: '',
            // src: 'https://stream7.iqilu.com/10339/upload_transcode/202002/18/20200218114723HDu3hhxqIT.mp4',
            type: 'video/mp4'
          }
        ]
      }
    }
  },
  mounted () {
    /* this.initFfmpegBool = true
    if (this.videoinfo.url) { // 有传过来的url参数
      this.videoInfo.baseInfo.name = this.videoinfo.name
      // 通过url加载视频资源
      this.initVideoCutByUrl(this.videoinfo.url)
    } */
    this.initFfmpeg()
  },
  filters: {
  },
  computed: {
    // 动态计算时间游标的值显示
    youbiaoLeft () {
      if (!this.videoInfo.currentTime) {
        return '0px'
      }
      let left = 0
      const percent = (Math.floor(this.videoInfo.currentTime) / this.videoInfo.baseInfo.duration).toFixed(2)
      // 计算总长100的百分之几,并减去左边的宽度
      left = percent * 1000 - this.$refs.left.offsetWidth
      return left + 'px'
    },
    transTimeCom () {
      i
<think>我们有两个主要部分:在Vue3中实现录屏功能,以及在Node.js使用@ffmpeg/ffmpeg将WebM转换为MP4。 由于录屏通常在浏览器端进行,而转换可以在浏览器端或服务器端进行。考虑到性能和文件处理,我们选择在Node.js服务器端进行转换。 步骤: 1.Vue3中使用MediaRecorder API进行录屏,得到WebM格式的视频。 2. 将录制的WebM视频上传到Node.js服务器。 3. 在Node.js服务器端使用@ffmpeg/ffmpeg将WebM转换为MP4。 4. 将转换后的MP4文件保存提供下载或播放。 详细步骤: 一、Vue3前端录屏实现 1. 创建Vue3组件,添加开始录制、停止录制和上传的按钮。 2. 使用navigator.mediaDevices.getDisplayMedia捕获屏幕流。 3. 使用MediaRecorder录制屏幕流,将数据存储在Blob中。 4. 将Blob转换为文件或FormData上传到服务器。 二、Node.js服务器端接收WebM文件转换为MP4 1. 创建Node.js服务器(使用Express)接收文件上传。 2. 使用@ffmpeg/ffmpeg上传的WebM文件转换为MP4。 3. 保存转换后的MP4文件,返回下载链接。 具体代码实现: 前端(Vue3组件):</think>### 在 Vue3 + Node.js 中实现录屏及 WebM 转 MP4 #### 一、Vue3 前端录屏实现 ```vue <template> <div> <video ref="preview" autoplay muted class="preview"></video> <button @click="startRecording">开始录制</button> <button @click="stopRecording" :disabled="!isRecording">停止录制</button> <button @click="uploadVideo" :disabled="!recordedBlob">上传转换</button> </div> </template> <script setup> import { ref } from 'vue'; const preview = ref(null); const mediaRecorder = ref(null); const recordedBlob = ref(null); const isRecording = ref(false); let recordedChunks = []; // 获取屏幕流开始录制 const startRecording = async () => { try { const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }); preview.value.srcObject = stream; recordedChunks = []; mediaRecorder.value = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9,opus' }); mediaRecorder.value.ondataavailable = (e) => { if (e.data.size > 0) recordedChunks.push(e.data); }; mediaRecorder.value.onstop = () => { recordedBlob.value = new Blob(recordedChunks, { type: 'video/webm' }); }; mediaRecorder.value.start(1000); // 每1秒收集一次数据 isRecording.value = true; } catch (err) { console.error('录屏错误:', err); } }; // 停止录制 const stopRecording = () => { mediaRecorder.value.stop(); preview.value.srcObject.getTracks().forEach(track => track.stop()); isRecording.value = false; }; // 上传到Node.js服务器 const uploadVideo = async () => { const formData = new FormData(); formData.append('video', recordedBlob.value, 'recording.webm'); try { const response = await fetch('http://localhost:3000/convert', { method: 'POST', body: formData }); const result = await response.json(); console.log('转换结果:', result); alert(`转换成功! 下载链接: ${result.downloadUrl}`); } catch (error) { console.error('上传失败:', error); } }; </script> ``` #### 二、Node.js 服务器端转换实现 ```javascript // server.js import express from 'express'; import multer from 'multer'; import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg'; import fs from 'fs'; import path from 'path'; const app = express(); const upload = multer({ dest: 'uploads/' }); const ffmpeg = createFFmpeg({ log: true }); // 初始化FFmpeg (async () => { await ffmpeg.load(); console.log('FFmpeg 初始化完成'); })(); app.post('/convert', upload.single('video'), async (req, res) => { try { const inputPath = req.file.path; const outputPath = `converted/${Date.now()}.mp4`; // 读取上传视频 ffmpeg.FS('writeFile', 'input.webm', await fetchFile(inputPath)); // 执行转换 (添加硬件加速参数提升性能) await ffmpeg.run( '-i', 'input.webm', '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', 'output.mp4' ); // 保存转换结果 const data = ffmpeg.FS('readFile', 'output.mp4'); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, Buffer.from(data)); // 清理临时文件 ffmpeg.FS('unlink', 'input.webm'); ffmpeg.FS('unlink', 'output.mp4'); fs.unlinkSync(inputPath); res.json({ success: true, downloadUrl: `/download?path=${encodeURIComponent(outputPath)}` }); } catch (error) { console.error('转换错误:', error); res.status(500).json({ error: '转换失败' }); } }); // 提供下载 app.get('/download', (req, res) => { const filePath = decodeURIComponent(req.query.path); res.download(filePath, 'recording.mp4', () => { fs.unlinkSync(filePath); // 下载后删除文件 }); }); app.listen(3000, () => console.log('服务器运行在 http://localhost:3000')); ``` #### 三、安装依赖 ```bash # Vue3 前端 npm install axios # Node.js 后端 npm install express multer @ffmpeg/ffmpeg ``` #### 四、性能优化技巧 1. **硬件加速** (Node.js端): ```javascript // 在ffmpeg.run参数中添加(根据硬件支持选择) '-hwaccel', 'cuda', // NVIDIA GPU '-hwaccel', 'videotoolbox' // macOS ``` 2. **降低分辨率** (减小文件小): ```javascript await ffmpeg.run( '-i', 'input.webm', '-vf', 'scale=1280:720', // 降为720p ... ); ``` 3. **前端流式上传** (文件优化): ```javascript // Vue3 中使用可读流分块上传 const reader = recordedBlob.value.stream().getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; // 分块上传逻辑 } ``` #### 五、安全注意事项 1. **文件类型验证** (Node.js端): ```javascript if (!req.file.mimetype.includes('video/webm')) { return res.status(400).send('无效文件类型'); } ``` 2. **路径安全处理**: ```javascript // 防止路径遍历攻击 const safePath = path.resolve('converted', path.basename(outputPath)); ``` 3. **设置超时限制**: ```javascript // 限制转换时间(单位:毫秒) await Promise.race([ ffmpeg.run(...), new Promise((_, reject) => setTimeout(() => reject(new Error('转换超时')), 30000) ) ]); ``` > **关键点总结**: > 1. 前端使用 `MediaRecorder` 捕获屏幕流生成 WebM > 2. 通过 FormData 上传到 Node.js 服务器 > 3. 服务器端使用 `@ffmpeg/ffmpeg` WASM 版进行格式转换 > 4. 转换后提供安全下载链接 > 5. 添加硬件加速参数可提升 3-5 倍性能[^1]
评论 25
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值