当前位置: 代码网 > 服务器>服务器>Linux > H5纯前端Webcodecs处理视频完整实现

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

2024年08月01日 Linux 我要评论
H5纯前端Webcodecs+mp4box+WebMMuxer+canvas处理视频,在视频上增加图片显示(可以实现水印效果),而且保留音频。提供完整实现代码。

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

首先参考的是微信小程序中提供的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, { keyframe: index % 100 == 0 });
              
          // 记得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)编码了。注意我们这里每100帧设置了1个关键帧,这样可以避免错误:每个簇最多为32.768秒;否则我们产出的视频超过32.768秒即会失败。在脚本第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, { keyframe: i % 100 == 0 });
            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等。最后希望本文对读者有一定的帮助。

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com