H5纯前端Webcodecs处理视频完整实现

最近公司想要举办一个粉丝群活动,需要实现在手机端给自拍视频增加相框、装饰的效果、并能保存下来,而且需要保留视频中的声音。

首先参考的是微信小程序中提供的videoDecoder,经过实验,微信小程序中的videoDecoder只能截取视频的一部分,所以无法实际使用。然后考虑H5中使用ffmpeg,使用其加水印功能。然后还有一个淘汰的选择:就是Webcodecs。虽然这个选择因为浏览器兼容问题被淘汰了,但是它仍然是一个值得被关注和收藏的技术。

Webcodecs是个实验性的API(2021年随着chrome 94发布),它提供了对浏览器中已存在的编解码器能力,可以解析原始视频帧、音频数据。有了Webcodecs,可以在浏览器中完成视频压缩、截取、增加特效、剪辑、格式转化等功能。虽然Webcodecs当前不被所有浏览器支持、但是已经有越来越多的浏览器开始采用了Webcodecs作为视频处理的标准组件。

上图是最新的Webcodecs文档中一个关键接口VideoDecoder中,对于浏览器兼容列举的部分截图,对于Chrome和Edge这2个浏览器,在PC和Mac上都测试过没有运行问题;而Mac上的Safari是要新版本(16.4是2023年3月发布的)运行的。

下面对于如何实现一个视频加贴图水印并导出的前端demo做详细说明。编写代码时,参考了一些已有的相关博客、文档和社区帖子的代码片段和说明,先列举在此。

- 入门WebCodecs-给视频添加水印

- mp4box.js加WebCodecs 解码MP4视频帧并渲染

- Webcodecs API 视频编解码实践

- 聊聊 WebCodecs 实现 GIF 视频转码 

- 「1.4万字」玩转前端 Video 播放器 | 多图预警

WebCodecs 音视频处理

mp4box 文档

muxer 文档

WebCodecs 官方文档

- 一个使用webm-muxer(和WebCodes)的demo

- 一个英语的讨论mp4box、WebCodes处理视频(包含audio)的帖子

本文介绍的demo页面,可见元素包含显示视频帧和覆盖贴图(水印)的画布、合成后的视频以及选择源视频、覆盖贴图图片和执行重编码这3个按钮。视频只支持MP4格式、图片可以是png、jpg和gif(会自动使用第1帧)。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MP4Box WebCodecs 实验</title>
    <script src="./mp4box.all.min.js"></script>
    <script src="./webm-muxer.js"></script>
  </head>
  <body>
    <h3>效果:</h3>
    <canvas id="canvas" width="360" height="640"></canvas>
    <h3>结果预览:</h3>
    <video id="result_view" controls><source id="videoSource" src="" type="video/mp4"></video>
    <p>
      <input type="file" id="chooseVideoInput" style="display:none" accept="video/mp4" onchange="handleFiles(this.files)">
      <button onclick="chooseVideo()">选择MP4视频并解码</button>
      <input type="file" id="chooseImgInput" style="display:none" accept="image/*" onchange="handleFiles(this.files)">
      <button onclick="chooseImg()">选择覆盖层图片</button>
      <button id="run">重编码MP4并保存</button>
    </p>
    <script>

    ......见后文 脚本第1~6部分

    </script>
  </body>
</html>

上面html头中引入了mp4box.all.min.js和webm-muxer.js这2个js库,webm-muxer.js可以在webm-muxer的demo页面中从Network调试面板中复制出代码(如下图)。mp4box.all.min.js可以下载https://cdn.jsdelivr.net/npm/mp4box@0.5.2/dist/mp4box.all.min.js获取。

深入JS脚本前,先把流程图附上,可以看出WebcodecsAPI的使用很简洁,而且数据和算法都是对称的。

脚本第1部分:检查浏览器是否支持 Webcodecs

脚本最开始查看当前环境是否支持VideoEncoder(浏览器是否兼容)、然后反馈查询结果。在Chrome和Edge本地调试和部署到服务器应该都可以通过校验、并且能顺利运行。注意,在服务器部署页面需要使用https而非http协议(见Webcodecs文档)。  

      if('VideoEncoder' in window){
        console.log("webcodecs is supported.")
      }else{
        console.error("webcodecs is not supported.")
        alert("webcodecs is not supported.")
      }

脚本第2部分:视频、覆盖贴图图片的路径声明与获取

      let mp4url = '';
      let coverImg = new Image();
      coverImg.onload = function(e) {
        //console.log(coverImg.width);
      };
      function chooseVideo() {
        document.getElementById('chooseVideoInput').click();
      }
      function chooseImg() {
        document.getElementById('chooseImgInput').click();
      }
      
      function handleFiles(files) {
        // 处理选中的文件
        //console.log(files);
        if(files[0].type.indexOf('image')==0){
          coverImg.src = URL.createObjectURL(files[0]);  // 覆盖层图片路径赋值
        }else if(files[0].type.indexOf('video/mp4')==0){
          mp4url = URL.createObjectURL(files[0]);  // 选中视频路径赋值
          console.log(mp4url);

          // 调用初始化视频处理
          StartDecode();
        }else{
          alert('不支持的文件格式');
        }
      }

视频、覆盖贴图图片的路径获取事件绑定到了html的按钮和隐藏input上了,点击按钮可以在本地磁盘中选择视频和覆盖图片。选择完视频,即可开始视频解码,调用的StartDecode函数将在后面定义。

脚本第3部分:视频处理关键全局变量的声明,处理方法声明与执行(先解编码视频,脚本第5部分再解编码音频)

      // mp4box file
      let mp4box = null;

      // 视频轨道,解、编码器
      let videoTrack = null;
      let videoDecoder = null;
      let videoEncoder = null;
      // 这个就是最终解码出来的视频画面序列文件
      let videoFrames = [];

      // 采样上限
      const NBSampleMax = 1000;

      // 视、音频公用变量
      let nbSampleTotal = 0;
      let countSample = 0;
      let videoDuration = 0;

      // 视频的宽度和高度
      let videoW = 360;
      let videoH = 640;

      // 音频轨道,解、编码器,帧
      let audioTrack = null;
      let audioDecoder = null;
      let audioEncoder = null;
      let decodedAudioFrames = [];

      // 封装器
      let muxer = null;

      
      // 视频路径获取后,可以开始初始化 mp4box、执行视频解码
      async function StartDecode(){
        // 下面是视频解码的处理逻辑,使用mp4box.js获取视频信息
        // 使用 Webcodecs API 进行解码

        mp4box = MP4Box.createFile();

        // 获取视频的arraybuffer数据,启动mp4box
        fetch(mp4url).then(res => res.arrayBuffer()).then(buffer => {
          // 因为文件较小,所以直接一次性写入
          // 如果文件较大,则需要res.body.getReader()创建reader对象,每次读取一部分数据
          // reader.read().then(({ done, value })
          buffer.fileStart = 0;
          mp4box.appendBuffer(buffer);
          mp4box.flush();
        });

        // 这个是额外的处理方法,不需要关心里面的细节
        const getExtradata = () => {
          // 生成VideoDecoder.configure需要的description信息
          const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];

          const box = entry.avcC ?? entry.hvcC ?? entry.vpcC ?? entry.av1C;
          if (box != null) {
            const stream = new DataStream(
              undefined,
              0,
              DataStream.BIG_ENDIAN
            )
            box.write(stream)
            // slice()方法的作用是移除moov box的header信息
            return new Uint8Array(stream.buffer.slice(8))
          }
        };

        // 创建视频解码器
        function createVideoDecoder(){
          videoDecoder = new VideoDecoder({
            output: (videoFrame) => {
              createImageBitmap(videoFrame).then((img) => {
                console.log('videoFrames.push')
                videoFrames.push({
                  img,
                  duration: videoFrame.duration,
                  timestamp: videoFrame.timestamp
                });
                videoFrame.close();
              });
            },
            error: (err) => {
              console.error('videoDecoder错误:', err);
            }
          });

          const config = {
            codec: videoTrack.codec,
            codedWidth: videoW,
            codedHeight: videoH,
            description: getExtradata()
          };
          VideoDecoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
            console.log('VideoDecoder.isConfigSupported: supported');
          }).catch(()=>{
            console.error('VideoDecoder.isConfigSupported: NOT supported');
          });
          videoDecoder.configure(config);
        }
        // 创建视频编码器
        function createVideoEncoder(){
          videoEncoder = new VideoEncoder({
            output: (chunk, meta) => {
                muxer.addVideoChunk(chunk, meta);
            },
            error: e => {
                console.log('VideoEncoder', e.message);
            },
          });

          const config = {
            //codec: videoTrack.codec,
            codec: 'vp09.00.10.08',
            width: videoW,
            height: videoH,
            bitrate: 2_000_000, // 2 Mbps
            framerate: 20,
          };

          VideoEncoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
            console.log('VideoEncoder.isConfigSupported: supported');
          }).catch(()=>{
            console.error('VideoEncoder.isConfigSupported: NOT supported');
          });
          videoEncoder.configure(config);
        }

        // 创建封装器
        function createMuxer(){
        // 封装chunk, 新建mp4-muxer
        // https://github.com/Vanilagy/webm-muxer?tab=readme-ov-file
        // https://vanilagy.github.io/webm-muxer/demo/
          muxer = new WebMMuxer.Muxer({
            target: new WebMMuxer.ArrayBufferTarget(),
            video: {
            //codec: videoTrack.codec,
            codec: 'V_VP9',
            width: videoW,
            height: videoH,
            },
            audio: audioTrack ? {
              codec: 'A_OPUS',
              sampleRate: audioTrack ? audioTrack.audio.sample_rate : 20000,
              numberOfChannels: 1
            } : undefined,
            fastStart: 'in-memory',
          })
        }

        // https://github.com/gpac/mp4box.js/issues/243
        // 在 mp4box.onReady 中初始化视频时长、视音频轨道、视频解码器编码器,开始视频采样
        mp4box.onReady = function (info) {
          console.log('info', info);
          // 视频时长(毫秒(1/1000秒))
          //videoDuration = info.duration;  // 有问题
          videoDuration = info.duration / info.timescale * 1000;  // 这才是 最终播放时长

          // 记住视频轨道信息,onSamples匹配的时候需要
          videoTrack = info.videoTracks[0];
          // 获取 audioTrack
          audioTrack = info.audioTracks[0];
          console.log("audioTrack ",audioTrack)  // Todo: 没有音频轨道的兼容处理

          if (videoTrack != null) {
            mp4box.setExtractionOptions(videoTrack.id, 'video', { 
              nbSamples: NBSampleMax  // 抓取帧数上限
            })
          }

          // 视频的宽度和高度
          videoW = videoTrack.track_width;
          videoH = videoTrack.track_height;
          console.log('videoW: ', videoW, 'videoH: ', videoH);

          // 创建视频解码器、编码器(音频解码器、编码器在处理完视频后会创建)
          createVideoDecoder();
          createVideoEncoder();
          
          // 这里可以初始化封装器了
          createMuxer();

          nbSampleTotal = videoTrack.nb_samples;
          console.log('video nbSampleTotal: ' + nbSampleTotal);

          // 开始视频采样
          mp4box.start();
        };
        // 在 mp4box.onSamples 中获取到离散的数据块,交给视、音频解码
        mp4box.onSamples = function (trackId, ref, samples) {
          // samples其实就是采样数据了
          if (videoTrack.id === trackId) {
            console.log('mp4box onSamples videoTrack');
            mp4box.stop();

            countSample += samples.length;

            for (const sample of samples) {
              const type = sample.is_sync ? 'key' : 'delta';
              //debugger
              const chunk = new EncodedVideoChunk({
                type,
                timestamp: sample.cts,
                duration: sample.duration,
                data: sample.data
              });

              videoDecoder.decode(chunk);
            }

            if (countSample === nbSampleTotal) {
              videoDecoder.flush();
            }
            return;
          }

          if (audioTrack.id === trackId) {
            console.log('mp4box onSamples audioTrack');
            mp4box.stop();
            countSample += samples.length;

            for (const sample of samples) {
              const type = sample.is_sync ? 'key' : 'delta';

              const chunk = new EncodedAudioChunk({
                type,
                timestamp: sample.cts,
                duration: sample.duration,
                data: sample.data,
                offset: sample.offset,
              });

              audioDecoder.decode(chunk);
            }

            if (countSample === nbSampleTotal) {
              audioDecoder.flush();
            }
          }
        };

      }  // end function StartDecode

这个是解码功能实现最重要的代码片段,在点击选择视频按钮选好视频后执行。

正式处理视频前,我们先定义一些全局变量:mp4box 文件、视频音频的轨道,解、编码器和帧数组、采样上限(最多的帧数)、视频时长、视频的宽度和高度、视频封装器(muxer)。

全局变量声明完后首先需要创建一个mp4box文件实例,内置的fetch执行后把视频数据流输送给mp4box,mp4box的flush触发回调onReady,在onReady中我们能够得到视频文件的详细信息:播放时长、视频音频轨道和视频宽度高度;然后立即初始化视频解码器、编码器、以及最后合成视频的封装器(封装器对于视频音频的格式设置这里参考了官方demo写死了,实测生成的视频在iPhone、安卓手机上可以播放)。然后在onReady的最后调用mp4box.start()、会触发回调onSamples。

onSamples中mp4box会把视频、音频的数据块传入,这时就需要视频、音频解码器解码了。先注意创建视频解码器(videoDecoder)中的output回调,它会把解码后的帧作为图像数据源传入,此时我们会把它作为bitmap保存到视频帧数组中(后面处理每一帧时需要用到)。

脚本第4部分:对解码后的视频帧的再处理

      const canvas = document.getElementById('canvas');
      const context = canvas.getContext("2d");

      // 下面就是点击按钮,然后执行混合模式层的绘制了
      // 以上就是解码过程,下面是将解码的videoFrames应用在canvas上,做特效展示
      const button = document.querySelector('#run');
      button.addEventListener('click', () => {
        if (!videoFrames.length) {
          console.error('视频解码尚未完成,请稍等');
          return;
        }
        canvas.width = videoW;
        canvas.height = videoH;
        // 从videoFrames中取出数据,应用到canvas上
        let index = 0;
        let startTime = document.timeline.currentTime;
        // 绘制方法
        const draw = () => {
          const { img, timestamp, duration } = videoFrames[index];
          let elapsedTime = document.timeline.currentTime - startTime;

          console.log(timestamp, duration);

          // 清除画布
          context.clearRect(0, 0, canvas.width, canvas.height);
          // 绘制图片,img是从视频中抓取的背景图
          context.drawImage(img, 0, 0, canvas.width, canvas.height);
          // 在画布中心绘制覆盖层图片
          //context.drawImage(coverImg, (canvas.width - coverImg.width) / 2, (canvas.height - coverImg.height) / 2, coverImg.width, coverImg.height);
          // 增加旋转动效测试
          context.save();
          // 平移到画布中心
          context.translate(canvas.width / 2, canvas.height / 2);
          context.rotate(elapsedTime * 0.005);
          context.drawImage(coverImg, -coverImg.width / 2, -coverImg.height / 2, coverImg.width, coverImg.height);
          context.restore();

          // 这里特殊处理了一下将开头帧的timestamp设为0
          const newFrame = new VideoFrame(canvas, {
            //timestamp: index==0 ? 0 : elapsedTime * 1000
            timestamp: index==0 ? 0 : index * (videoDuration / videoFrames.length) * 1000
          });
            
          // 编码这一帧
          videoEncoder.encode(newFrame);
              
          // 记得close
          newFrame.close();

          // 开始下一帧绘制
          index++;
          console.log(index);

          if (index === videoFrames.length) {
            // 重新开始
            //index = 0;
            // 视频编码完成,转处理音频
            onVideoDemuxingComplete(); 
          }
          else{
            // 暂停1帧停留的时间后继续处理下一帧
            setTimeout(draw, videoDuration / videoFrames.length);
          }
        }

        draw();  // 开始canvas=>videoEncoder

之前(脚本第3部分)是选择完视频后执行的,其中最后1步mp4box.onSamples已经完成了视频流到帧的解码转换。现在我们有视频帧数组(videoFrames),我们可以利用canvas对视频做特别的处理,这里演示了在视频画面中心增加一个旋转纹理的效果。每一帧背景(视频每帧画面)和覆盖纹理绘制完以后,我们把这一帧canvas合成的画面转成一个VideoFrame;注意timestamp对于第1帧必须是0,后面的帧的时间戳也要正确计算,否则导出的视频长度和播放速度会混乱。完成了VideoFrame的实例化,我们就可以把它交给视频编码器(videoEncoder)编码了。在脚本第3部分中,可以看到videoEncoder的output回调会把编码得到的数据块供给 muxer的addVideoChunk方法、即填充视频流给封装器。

当视频帧都处理完毕、我们关闭视频处理、再开始音频处理。

脚本第5部分:视频处理完了,处理音频

        // 创建音频解码器
        const setupAudioDecoder = () => {
          const config = {
            codec: audioTrack.codec,
            sampleRate: audioTrack.audio.sample_rate,
            numberOfChannels: audioTrack.audio.channel_count,
          }
          
          audioDecoder = new AudioDecoder({
            output: (audioFrame) => {
              console.log('decodedAudioFrames.push');
              decodedAudioFrames.push(audioFrame);

              // 如果audioFrame都push完了,启动音频encode
              if(decodedAudioFrames.length == nbSampleTotal){
                setTimeout(()=>{
                  runAudioEncoding();
                }, 50);
              }
            },
            error: (err) => {
              console.error('AudioDecoder error : ', err);
            },
          });

          AudioDecoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
              console.log('AudioDecoder.isConfigSupported: supported');
          }).catch(()=>{
              console.error('AudioDecoder.isConfigSupported: NOT supported');
          });
          audioDecoder.configure(config);
          mp4box.setExtractionOptions(audioTrack.id, 'audio', { nbSamples: NBSampleMax });
        };
        // 创建音频编码器
        const setupAudioEncoder = () => {
          const config = {
            // codec: audioTrack.codec, // AudioEncoder does not support this field
            codec: 'opus',
            sampleRate: audioTrack.audio.sample_rate,
            numberOfChannels: audioTrack.audio.channel_count,
            bitrate: audioTrack.bitrate,
          }
          audioEncoder = new AudioEncoder({
            output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
            error: e => console.error(e)
          });
          AudioEncoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
              console.log('AudioEncoder.isConfigSupported: supported');
          }).catch(()=>{
              console.error('AudioEncoder.isConfigSupported: NOT supported');
          });
          audioEncoder.configure(config);
        };

        // 视频编码完,开始处理音频
        async function onVideoDemuxingComplete(){
          await videoEncoder?.flush();

          nbSampleTotal = audioTrack.nb_samples;
          console.log('audio nbSampleTotal: ' + nbSampleTotal);
          countSample = 0;
          
          setupAudioDecoder();
          setupAudioEncoder();

          mp4box.start();
        }

        // 音频解码完,编码音频,编码完成后合成video
        async function runAudioEncoding(){
          console.log('start Audio Encoding');
          for(var i = 0; i < decodedAudioFrames.length; i++){
            let audioFrame = decodedAudioFrames[i];
            audioEncoder.encode(audioFrame);
            audioFrame.close();
          }

          await audioEncoder?.flush();

          // 合成video
          videoAudioProcessFinal();
        }

音频解码、编码器的作用和使用方法基本和视频解编码器相同,这里也没有对音频的再处理。所以处理音频即简单地创建音频解码、编码器后开启mp4box的音频采样(会触发onSamples回调),然后把解析后的音频帧储存到数组、再交给音频编码器、和视频编码类似地audioEncoder的output中使用封装器addAudioChunk方法、编码后音频数据填入封装器。

每一个音频帧处理完,可以进入最后一步:合成video了。

脚本第6部分:最后一步,视频、音频处理完了,合成video

      function videoAudioProcessFinal(){
          console.log('视频、音频已处理完,合成video');

          // 编码完成后
          muxer.finalize();
          const { buffer } = muxer.target;

          // 视频数据转Blob
          const blob = new Blob([buffer], { type: 'video/mp4' });

          // 设置预览视频
          var video = document.getElementById('result_view');
          var source = document.getElementById('videoSource');
          let previewVideoPath = URL.createObjectURL( blob );
          source.src = previewVideoPath;
          video.load();


          // 将这个视频下载到本地
          const a = document.createElement('a');
          a.download = 'test' + '.mp4';
          a.href = URL.createObjectURL(blob);
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
        }

        // 按钮禁用
        button.disabled = true;
      }); // 点击编码按钮回调结束

最后一步,执行muxer.finalize()完结处理,把视频数据从muxer的target中取出,使用blob和URL.createObjectURL可以得到临时结果视频路径,然后就可以传到页面video显示、并下载。

脚本第4、5、6部分都是在“重编码MP4”按钮点击事件中执行的。脚本第1至6部分是连续的,读者可以不用增删代码把6个部分拼接在一起即获得(html内script的)完整的代码。

代码中打印了各个阶段执行的日志信息,可以打开Console查看执行情况。

至此,本文完成了基于Webcodecs、mp4box和WebMMuxer的前端视频加贴图效果的完整实现流程的说明。在此基础上、还可以实现很多其他效果,比如视频剪辑、MP4转Gif等。最后希望本文对读者有一定的帮助。

  • 13
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
H5商城前端静态页面是指使用H5技术(HTML5、CSS3、JavaScript)开发的商城网站,其中没有后端交互和数据存储,所有页面和功能都在浏览器端实现。 这种静态页面适用于一些简单的电子商务需求,例如展示商品信息、查看商品详情、加入购物车等功能,但不能处理用户登录、订单提交、支付等复杂操作。 在H5商城前端静态页面的开发中,可以使用HTML5实现网页的结构和内容,使用CSS3进行页面的样式设计和布局,使用JavaScript实现页面的交互和动态效果。 通过HTML5的语义化标签,可以提供良好的页面结构,方便搜索引擎的抓取和理解。同时,CSS3的各种特性和选择器,可以让页面具有美观的视觉效果和响应式布局。而JavaScript的使用可以实现页面元素的交互操作,例如商品展示、轮播图、下拉菜单等。 开发过程中可以使用一些前端框架或库,如Bootstrap、Vue.js、React等,来简化开发流程和提高页面性能。 需要注意的是,H5商城前端静态页面不能直接与后台进行数据交互,因此无法实现用户登录、购物车同步等功能。如果需要实现这些功能,需要结合后台技术,如PHP、Node.js等,配合前端的AJAX请求来完成数据的交互和存储。 总之,H5商城前端静态页面适用于简单的商城需求,通过HTML、CSS和JavaScript的灵活运用,可以实现良好的用户体验和页面效果,但对于复杂的业务逻辑和数据处理,还需要后端的支持。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值