浏览器中已经存在了许多和媒体相关的API,例如HTMLVideoElementMediaSourceExtensionsWebAudio等等,但这些都属于高级API,如果开发者想要对编解码过程进行控制单凭这些API有些力不从心。

想要进行底层操作也是可以的,通过Wasm引入ffmpeg,在浏览器进行编解码操作,但是浏览器本身已经有ffmpeg了,再进行引入有些冗余,而且通过Wasm的ffmpeg性能上也不如原生应用。

于是Chrome94之后,开放了更加底层的WebCodecs API,开发者可以对音视频进行底层的编解码操作,但是目前开放的支持的编解码格式是有限的。

下图大体描述了WebCodecs的一些使用流程:

image-20231130211937775

解码所需的数据来源有两类,一种是文件,通过解析文件获得Buffer,然后构造EncodeVideoChunk或者EncodedAudioChunk(简称音视频数据),通过VideoDecoder或者AudioDecoder(简称解码器)对音视频数据进行解码,得到VideoFrame或者AudioData(简称帧数据)。另一种是输入设备捕获的实时流,通过MediaStreamTrackProcessor获取ReadableStream,通过reader读取到帧数据。

如果需要对视频进行编辑,可以将视频帧渲染到Canvas中进行操作,然后重新写入视频帧,再通过VideoEncoder和AudioEncoder(简称编码器)对帧数据进行编码得到音视频数据,最后将音视频数据进行封装或者播放。

🔔:刚接触WebCodecs大概率是懵逼的,研究两三天,动手操作一下就懂了。

在执行WebCodecs代码之前一定要检测当前浏览器是否支持WebCodecs

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

因为还是实验性阶段,兼容性较差

image-20231130211243976

视频的两种输入方式

Network/LocalFile

我们以MP4格式视频为例,这里借助下MP4Box.js来进行解封装。

准备工作,使用http-server来托管一个静态的mp4文件(Nginx也可以,就是有点麻烦)。

首先进行拉取文件并解封装

const file = MP4Box.createFile();

file.onReady = (info) => {
  const videoTrack = info.videoTracks[0];
  file.setExtractionOptions(videoTrack.id, 'video');
  file.start();
}

file.onSamples = (id, user, samples) => {
  for (const sample of samples) {
    const chunk = new EncodedVideoChunk({
      type: sample.is_sync ? 'key' : 'delta',
      timestamp: sample.cts,
      duration: sample.duration,
      data: sample.data
    });
  }
}

fetch('/video/beyond.mp4')
  .then(res => res.arrayBuffer())
  .then(buffer => {
    buffer.fileStart = 0;
    file.appendBuffer(buffer)
  })

文件较小可以直接获取buffer,如果文件较大,或者是直播场景则需要通过流的方式读取

fetch('/video/beyond.mp4')
   .then(res => res.body?.getReader())
   .then(async reader => {
     let size = 0;

     while (reader) {
       const { done, value } = await reader.read();
       if (value) {
         const buffer = value.buffer
         buffer.fileStart = size;
         size += buffer.byteLength;
         file.appendBuffer(buffer);
       }

       if (done)
         break;
     }
 })

onReady事件中可以获取视频文件的一些信息,会在接下来初始化解码器的时候用到;onSamples表示获取到可用视频数据了,根据视频信息构造EncodedVideoChunk,如果是关键帧类型需要设置为key,否则设置为delta,构造出的chunk将会在下面使用。

import {DataStream} from 'mp4box'

let videoTrackId;
const videoFrames = [];

const getExtradata = (file: MP4Box.MP4File) => {
  const entry = file.moov?.traks[0].mdia?.minf?.stbl?.stsd?.entries[0];
  const box = entry?.avcC ?? entry?.hvcC ?? entry?.vpcC;
  if (box != null) {
    const stream = new DataStream(
      undefined,
      0,
      DataStream.BIG_ENDIAN
    );
    box.write(stream);
    return new Uint8Array(stream.buffer.slice(8));
  }
};

const videoDecoder = new VideoDecoder({
  output: (videoFrame) => {
    createImageBitmap(videoFrame).then(img);
      videoFrame.close();
    });
  },
  error: (error) => {
    console.log(error)
  }
});

file.onReady = (info) => {
  // 省略部分代码
  const videoTrack = info.videoTracks[0];
  videoTrackId = videoTrack.id;
  
  if (videoTrack) {
    videoDecoder.configure({
      codec: videoTrack.codec,
      codedWidth: videoTrack.track_width,
      codedHeight: videoTrack.track_height,
      description: getExtradata(file),
    });
  }
}

file.onSamples = (id, user, samples) => {
  for (const sample of samples) {
    // 省略部分代码
    videoDecoder.decode(chunk);
  }
}

  function draw() {
    const canvas = document.getElementById("canvas") as HTMLCanvasElement;
    const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
    const img = videoFrames.shift();

    ctx.drawImage(img, 0, 0)
    requestAnimationFrame(draw)
  }

getExtradata用于添加元信息描述,如果不添加的话会报错image-20231201203738413

最终渲染效果如下。

image-20231202212915453

MediaStream

代表性的两种媒体流就是摄像头和屏幕共享,我们选取屏幕共享来进行演示(二者操作完全一致)。

const canvas = document.getElementById('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

navigator.mediaDevices.getDisplayMedia({
  video: {
    width: 1920,
    height: 1080,
  },
})
  .then(stream => {
    const track = stream.getVideoTracks()[0]
    const processor = new MediaStreamTrackProcessor({ track }).readable.getReader();

    const processFrame = async () => {
      const { value, done } = await processor.read();
console.log(done);

      if (!done) {
        const image = await createImageBitmap(value);
        ctx.drawImage(image, 0, 0, 1920, 1080);
        value.close();
        processFrame();
      }
    }
    processFrame()
  }).catch((error) => {
    console.error('Error accessing camera:', error);
  });

过程相较于文件输入要简单不少,但是MediaStreamTrackProcessor在VSCode中报错找不到,在浏览器中可以正常执行,原因不详,可能是实验性API没有被编辑器支持。

得到的效果如下

image-20231202113146870

视频编辑

视频的编辑我们需要用到前端的瑞士军刀——Canvas来进行处理。下面我们会举几个常见的例子来演示下,更多的操作就发挥你的想象力吧。

镜像反转

通过canvas的反转操作来进行镜像操作,在解码器中,获取到VideoFrame的时候渲染到Canvas进行操作,然后再通过当前Canvas的状态获取ImageBitmap进行保存。

let cnt = 1;
const videoDecoder = new VideoDecoder({
  output: (videoFrame) => {
    if (transCtx) {
      transCtx.clearRect(0, 0, canvas.width, canvas.height);
      // 进行镜像反转
      if (cnt === 1) {
        transCtx.scale(1, -1);
        transCtx.translate(0, -canvas.height);
      }
      transCtx.drawImage(videoFrame, 0, 0)
      transCtx.font = '30px Arial';
      transCtx.fillStyle = 'red';
      transCtx.fillText('Hello World', 0, 100)
      cnt++;
    }
    createImageBitmap(transCanvas).then((img) => {
      videoFrames.current.push({ data: img, duration: videoFrame.duration, timestamp: videoFrame.timestamp });
      videoFrame.close();
    });
  },
  error: (error) => {
    console.log(error)
  }
});

最终实现的效果如下

image-20231202212756237

视频消费

解析编辑完成之后就需要对视频数据进行消费,否则不是白忙一场了,常见的消费方式有两种:播放、保存(本地/网络保存)。

播放

关于播放,上面的示例中有过很多播放了,目前通过VideoFrame转ImageBitmap渲染到Canvas中是唯一的播放WebCodecs解码产物的方式,或许在未来能有更优雅的处理方式。

保存

通过遍历我们解码的视频帧,逐帧进行编码,然后通过MP4Box的封装能力对视频进行封装。

const videoEncodingTrackOptions = {
  timescale: oneSecondInMicrosecond,
  width: 1920,
  height: 1080,
  nb_samples: nbSampleTotal.current,
  avcDecoderConfigRecord: null
};
console.log(videoFrameDurationInMicrosecond);

const videoEncodingSampleOptions = {
  duration: videoFrameDurationInMicrosecond,
  dts: 0,
  cts: 0,
  is_sync: false
};
let encodedVideoFrameCount = 0;
const outputFile = MP4Box.createFile();
let encodingVideoTrack = outputFile.addTrack(videoEncodingTrackOptions);

const encoder = new VideoEncoder({
  output: (encodedChunk, config) => {
    if (encodingVideoTrack == null) {
      videoEncodingTrackOptions.avcDecoderConfigRecord =
        config?.decoderConfig?.description;
      encodingVideoTrack = outputFile.addTrack(
        videoEncodingTrackOptions
      );
    }

    const buffer = new ArrayBuffer(encodedChunk.byteLength);
    encodedChunk.copyTo(buffer);

    videoEncodingSampleOptions.dts = videoEncodingSampleOptions.cts =
      encodedChunk.timestamp;
    videoEncodingSampleOptions.is_sync = encodedChunk.type == "key";

    outputFile.addSample(
      encodingVideoTrack,
      buffer,
      videoEncodingSampleOptions
    );
    encodedVideoFrameCount++;

    if (encodedVideoFrameCount == nbSampleTotal.current)
      onVideoEncodingComplete();
  },
  error: (error) => {
    console.log(error)
  }
})

encoder.configure({
  codec: 'avc1.4D0032',
  width: 1920,
  height: 1080,
  bitrate: 15000000,
  hardwareAcceleration: "prefer-hardware",
  framerate: videoFramerate,
  avc: { format: "avc" },
})

async function onVideoEncodingComplete() {
  encoder.close();
  outputFile.save('encode.mp4');
}

while (videoFrames.current.length) {
  const { data, duration, timestamp } = videoFrames.current.shift();
  const frame = new VideoFrame(data, { timestamp, duration })
  encoder.encode(frame)
  frame.close()
}

通过编码器将VideoFrame转为EncodedVideoChunk, 使用MP4Box对这些chunk进行封装,最后保存问文件,或者通过window.URL.createObjectURL创建地址进行播放。


前端小白