入门WebCodecs-给视频添加水印

前言

对于前端来说,视频解析操作一直是一个比较薄弱的环节。虽然web端提供了很多视频接口,这些接口都比较高级,但是没有将编解码操作暴露出来,使得我们无法对视频进行帧级别的操作。在之前有此类需求通常会借助WebAssembly的能力,使用诸如ffmpeg这些三方库来进行操作,但是wasm也有自身的缺点(打包过大、性能一般),体验终究不如原生。

现在有了WebCodecs,将浏览器的视频编解码能力暴露出来,使得我们在前端对视频的处理方便多了。关于这个API的具体内容可以查看mdn文档 developer.mozilla.org/en-US/docs/...

开工

目标

从本地或者url获取一个mp4格式的视频,给视频添加水印后将视频下载到本地

基本思路

基本思路如上图,我们按照步骤一步一步来分做。本次我们先不处理音频,所以获取到的视频是没有声音的,声音的编解码留到之后在做

具体操作

解封装

MP4文件作为一个封装格式,内部既有音频轨和视频轨,我们要获得视频帧的第一步就是首先把视频流从文件中解析出来,这一步我们使用之前用到的MP4box.js。

第一步我们首先先把视频传给mp4box

JavaScript 复制代码
// 这里使用网络视频,首先先fetch下来
const file = await fetch(
    'https://lf3-static.bytednsdoc.com/obj/eden-cn/1eh7vhanuvoln/ght-test/0-3673208261256(1).mp4',
  );
  const arraybuffer = await file.arrayBuffer();
  
  const mp4box = MP4Box.createFile();
  
  // 监听onReady回调
  mp4box.onReady = handleReady
  
  // 将刚刚fetch好的arraybuffer传给mp4box并开始解析
  arraybuffer!.fileStart = 0;
  mp4box.appendBuffer(arraybuffer as any);
  mp4box.flush();

在mp4box解析好后会触发我们传入的onReady回调,参数如下图

这些信息主要是mp4的文件信息,里边并没有视频流本体,我们需要在ready后传入onSamples回调,并触发start方法来获取到视频的sample(经过编码的一帧)的数据。mp4box是按照chunk解码的,一次会传入好几个sample

JavaScript 复制代码
export async function handleReady(
  info,
) {
  console.log('info', info);
  
  ......
  
  mp4box.onSamples = (trackId, ref, samples) => {
    for (const sample of samples) {
    }
  };

}

sample的内容如下

可以看到这里边包含了每帧的实际数据(data)和一些诸如duration之类的基本信息,下一步我们就可以把他传给解码器进行解码了

解码

解码这里我们就要用到WebCodecs api提供的VideoEncoder了,VideoDecoder可以将编码过的视频帧解析成VideoFrame,即实际的像素帧。首先我们创建一个VideoEncoder

JavaScript 复制代码
// in handleReady 回调

const videoDecoder = new VideoDecoder({
    output: handleDecode,
    error: err => {
      console.error('videoDecoder错误:', err);
    },
  });

// 视频轨
const videoTrack = info.videoTracks[0]; 
 
const videoW = videoTrack.track_width;
const videoH = videoTrack.track_height;

const config = {
    codec: videoTrack.codec,
    codedWidth: videoW,
    codedHeight: videoH,
    description: getExtraData(mp4box, videoTrack),
  }; 
  
const { supported } = await VideoDecoder.isConfigSupported(config);
if (!supported) { // 判断解析配置是否支持
  console.error('NOT supported');
}
// 传入解析配置
videoDecoder.configure(config);  

对于解码器所接受的config的字段,codedWidth和codedHeight都比较好理解,分别代指视频的宽和高。

而codec则是指定所使用的解码器,对于我们使用的MIME type为video/mp4的情况,codec的值是avc1.4d002a(内部封装的AVC,也就是H.264视频),在这里我们直接使用mp4box解析出来的值即可。具体可以参考mdn文档

而对于description,官方文档上的解释则是

An ArrayBuffer, a TypedArray, or a DataView containing a sequence of codec specific bytes, commonly known as extradata.

直译为传给解码器解析的特定序列,比较费解。我们看官方demo的getExtraData实现如下

JavaScript 复制代码
function getExtraData(file: any, track: any) {
  const trak = file.getTrackById(track.id);
  for (const entry of trak.mdia.minf.stbl.stsd.entries) {
    const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C;
    if (box) {
      const stream = new MP4Box.DataStream(
        undefined,
        0,
        MP4Box.DataStream.BIG_ENDIAN,
      );
      box.write(stream);
      return new Uint8Array(stream.buffer, 8); // Remove the box header.
    }
  }
}

可以看到实际上是取了mdia.minf.stbl.stsd box中的信息,从MP4格式简析我们知道,stsd box存取了解码所需的信息,这下就理解了,VideoDecoder解码果然不例外也需要这个值。

在创建好解码器后,我们就可以将视频流传给解码器了,这里解码器接受EncodedVideoChunk类型的数据

JavaScript 复制代码
// in onSamples 回调
for (const sample of samples) {
      const type = sample.is_sync ? 'key' : 'delta';
      const chunk = new EncodedVideoChunk({
        type,
        timestamp: sample.cts,
        duration: sample.duration,
        data: sample.data,
      });
      videoDecoder.decode(chunk);
    }

其中type标识了这个帧是否是关键帧,直接取sample.is_sync字段即可

视频流传入解码器后,解析好的视频帧会以VideoFrame的格式通过之前传入的handleDecode回调传出,我们就可以进行下一步操作了

JavaScript 复制代码
export function handleDecode(frame: VideoFrame) {
  // 暂存一下
  pendingFrames.push(frame);
  if (underflow) {
    setTimeout(renderFrame, 0);
  }
}

加水印

加水印就比较简单了,VideoFrame可以被draw到canvas上去,直接使用canvas操作就可以

JavaScript 复制代码
async function renderFrame() {
  underflow = pendingFrames.length === 0;
  if (underflow) {
    return;
  }
  // 帧的解析和帧的实际时间并不对应,这里按照timestamp控制一下模拟视频速度进行播放
  const frame = pendingFrames.shift()!;
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise(r => {
    setTimeout(r, timeUntilNextFrame);
  });
  
  // 把视频帧先画在画布上
  ctx.drawImage(frame, 0, 0);
  // 把水印draw在画布上
  drawWaterMarker(ctx, canvas);
  
  // 不用的videoFrame及时close,避免内存泄漏
  frame.close();
  
  setTimeout(renderFrame, 0);
}

这里画水印的方法比较简单,就是把文字draw到画布上即可,具体就不介绍了。加好水印的效果如下

编码

带水印的视频已经成功的被draw到canvas上去了,下一步我们要将canvas画布上的内容编码成视频帧。

这一步要用到WebCodecs api提供的VideoEncoder。和VideoDecoder正相对的,VideoDecoder接受VideoFrame帧并将其编码为EncodedVideoChunk

首先我们新建一个VideoEncoder,和VideoDecoder差不多

JavaScript 复制代码
const videoEncoder = new VideoEncoder({
    output: handleEncode,
    error: e => {
      console.log('VideoEncoder', e.message);
    },
  });

  const config = {
    codec: videoTrack.codec,
    width: videoW,
    height: videoH,
    bitrate: 2_000_000, // 2 Mbps
    framerate: 30,
  };
  
  const { supported: enSupported } = await VideoEncoder.isConfigSupported(
    config,
  );
  if (!enSupported) {
    console.error('NOT enSupported');
  }
  videoEncoder.configure(config);

然后我们需要获取从canvas中创建当前帧VideoFrame,将每一帧传给编码器

JavaScript 复制代码
 // 这里特殊处理了一下将开头帧的timestamp设为0
 const newFrame = new VideoFrame(canvas, {
    timestamp: isfirst ? 0 : frame.timestamp,
  });
  isfirst = false;
  
  videoEncoder.encode(newFrame);
  
  // 记得close
  newFrame.close();

开始解析后,handleEncode回调就会源源不断的接收到解析好的EncodedVideoChunk。下一步我们就是将EncodedVideoChunk封装成一个可播放的mp4文件。

封装(mux)

上一步的EncodedVideoChunk对应的实际上就是mp4中的一个chunk,我们要做的是要首先将chunk组装成视频流,然后将视频流作为一个视频轨道封装到mp4文件中。

这里介绍一下muxer这个说法,这实际是个缩写。视频音频流封装这个过程在英文中又叫multiplex(多路复用),简写就变成了mux,则封装器就是一个muxer/multiplexer(多路复用器)。同样demuxer的意思也比较明显了

封装chunk的工作并不简单,mp4box也没有提供封装功能,这里我们使用mp4-muxer来做这一步。首先新建一个muxer

JavaScript 复制代码
const muxer = new Muxer({
    target: new ArrayBufferTarget(),
    video: {
      codec: videoTrack.codec,
      width: videoW,
      height: videoH,
    },
    fastStart: 'in-memory',
  });

然后将EncodedVideoChunk送给muxer,全部完成就可以获取到mp4文件

JavaScript 复制代码
// in handleEncode 
  muxer.addVideoChunk(chunk, metadata);

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

然后我们将这个视频下载到本地,就完成了

JavaScript 复制代码
const blob = new Blob([buffer], { type: 'video/mp4' });

          const a = document.createElement('a');
          a.download = 'test' + '.mp4';
          a.href = URL.createObjectURL(blob);
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);

最终效果

相关推荐
WilliamLuo1 天前
MP4结构初识-第一篇
前端·javascript·音视频开发
音视频牛哥7 天前
Android平台如何拉取RTSP|RTMP流并转发至轻量级RTSP服务?
音视频开发·视频编码·直播
声知视界7 天前
音视频基础能力之 iOS 视频篇(一):视频采集
音视频开发
关键帧Keyframe10 天前
音视频面试题集锦第 15 期 | 编辑 SDK 架构 | 直播回声 | 播放器架构
音视频开发·视频编码·客户端
关键帧Keyframe15 天前
iOS 不用 libyuv 也能高效实现 RGB/YUV 数据转换丨音视频工业实战
音视频开发·视频编码·客户端
关键帧Keyframe17 天前
音视频面试题集锦第 7 期
音视频开发·视频编码·客户端
关键帧Keyframe17 天前
音视频面试题集锦第 8 期
ios·音视频开发·客户端
蚝油菜花22 天前
MimicTalk:字节跳动和浙江大学联合推出 15 分钟生成 3D 说话人脸视频的生成模型
人工智能·开源·音视频开发
音视频牛哥24 天前
Android平台RTSP|RTMP播放器高效率如何回调YUV或RGB数据?
音视频开发·视频编码·直播
<Sunny>1 个月前
MPP音视频总结
音视频开发·1024程序员节·海思mpp