# Web 音视频(三)在浏览器中创建视频

Web 音视频目录

音视频工作流程

在 WebCodecs 之前,由于编解码能力的缺失,几乎无法在纯浏览器中编辑、创建视频。
WebCodecs 补齐了编解码能力,相当于在浏览器中提供了视频创作能力。

预计 WebCodecs 将会像 HTML5 技术(Video、Audio、MSE...)一样对用户习惯带来巨大改变,HTML5 作用于视频消费端,WebCodecs 作用于视频生产端。

本章介绍如何在浏览器中创建视频,如何解析视频请阅读上一章

你可以跳过原理介绍,直接查看 WebAV 生成 MP4 示例

# 采集与编码

前面的文章已介绍过 WebCodecs 使用 VideoFrame、AudioData 来描述音视频原始数据。
请参考 WebCodecs 核心 API

常见的音视频源有:MediaStream(摄像头、麦克风、分享屏幕)、Canvas、Video 标签、文件流等...

第一步,将这些源对象转换成 VideoFrame、AudioData 对象,方法有:

  1. 使用 MediaStreamTrackProcessor (opens new window) 将 MediaStream 转换为 ReadableStream<VideoFrame> 、 ReadableStream<AudioData>MDN 有示例代码
  2. 直接将 Canvas、Video 标签传递给 VideoFrame 的构建函数 new VideoFrame(canvas)
  3. 由解码器(VideoDecoder 、 AudioDecoder)解码本地或网络文件,得到 VideoFrame、AudioData
  4. AudioContext (opens new window) 获取音频原始数据创建 AudioData 对象,后续【音频数据处理】文章再介绍

第二步,将 VideoFrame、AudioData 传入编码器(VideoEncoder、AudioEncoder)

const encoder = new VideoEncoder({
  error: console.error,
  output: (chunk, meta) => {
    // chunk: EncodedVideoChunk 等待封装
    // meta 在下一步封装 SDK 创建轨道时需要
  },
});
encoder.configure({
  codec: 'avc1.4D0032', // H264
  width: 1280,
  height: 720,
});

let timeoffset = 0;
let lastTime = performance.now();
setInterval(() => {
  const duration = (performance.now() - lastTime) * 1000;
  encoder.encode(
    new VideoFrame(canvas, {
      // 这一帧画面,持续 33ms,duration 单位 μs
      duration,
      timestamp: timeoffset,
    })
  );
  timeoffset += duration;
}, 33);

WARNING

当高频调用 encoder.encode 时应根据当前编码器的队列大小 encoder.encodeQueueSize 决定是否需要暂停,队列中的 VideoFrame 数量过多会爆掉显存,导致性能极其低下

# 封装

编码器(VideoEncoder、AudioEncoder)将一帧帧原始数据编码(压缩)后会输出 EncodedVideoChunk、EncodedAudioChunk 对象,然后由封装程序将他们封装(muxing)成对应格式的视频文件。

我们继续使用 mp4box.js 来演示封装 mp4 文件。

MP4 将一个编码后的数据包抽象为 Sample,与 EncodedVideoChunk、EncodedAudioChunk 对象一一对应。
MP4 将不同类型的数据(音频、视频)分组抽象为 Track,分组管理不同类型的 Sample。

代码示例
const file = mp4box.createFile()
// 创建视频轨道
const videoTrackId = file.addTrack({
  timescale: 1e6,
  width: 1280,
  height: 720,
  // meta 来原于 VideoEncoder output 的参数
  avcDecoderConfigRecord: meta.decoderConfig.description
})
// 创建音频轨道
const audioTrackId = file.addTrack({
  timescale: 1e6,
  samplerate: 48000,
  channel_count: 2,
  type: 'mp4a' // AAC
  // meta 来原于 AudioEncoder output 的参数
  description: createESDSBox(meta.decoderConfig.description)
})

/**
 * EncodedAudioChunk | EncodedVideoChunk 转换为 MP4 addSample 需要的参数
 */
function chunk2MP4SampleOpts (
  chunk: EncodedAudioChunk | EncodedVideoChunk
): SampleOpts & {
  data: ArrayBuffer
} {
  const buf = new ArrayBuffer(chunk.byteLength)
  chunk.copyTo(buf)
  const dts = chunk.timestamp
  return {
    duration: chunk.duration ?? 0,
    dts,
    cts: dts,
    is_sync: chunk.type === 'key',
    data: buf
  }
}

// VideoEncoder output chunk
const videoSample =  chunk2MP4SampleOpts(chunk)
file.addSample(videoTrackId, videoSample.data, videoSample)

// AudioEncoder output chunk
const audioSample =  chunk2MP4SampleOpts(chunk)
file.addSample(audioTrackId, audioSample.data, audioSample)

以上代码是为了将主要过程与 API 建立对应关系,实际上还需要比较复杂的流程控制逻辑,以及进一步了解 mp4 格式知识才能编写出完整可运行的程序。

TIP

  • addSample 前必须保证音视频轨道(addTrack)都已经创建完成
  • 创建音频轨道需要传递 description(esds box),否则某些播放器将无法播放声音

# 生成文件流

使用 mp4box.js 封装编码器输出的数据,我们持有的是一个 MP4File 对象(mp4box.createFile()),将 MP4File 对象转换成 ReadableStream 可以非常方便地写入本地文件、上传到服务器。

注意释放内存引用,避免内存泄露

代码不算太长,全部贴出来了
export function file2stream(
  file: MP4File,
  timeSlice: number,
  onCancel?: TCleanFn
): {
  stream: ReadableStream<Uint8Array>;
  stop: TCleanFn;
} {
  let timerId = 0;

  let sendedBoxIdx = 0;
  const boxes = file.boxes;
  const tracks: Array<{ track: TrakBoxParser; id: number }> = [];

  const deltaBuf = (): Uint8Array | null => {
    // boxes.length >= 4 表示完成了 ftyp moov,且有了第一个 moof mdat
    // 避免moov未完成时写入文件,导致文件无法被识别
    if (boxes.length < 4 || sendedBoxIdx >= boxes.length) return null;

    if (tracks.length === 0) {
      for (let i = 1; true; i += 1) {
        const track = file.getTrackById(i);
        if (track == null) break;
        tracks.push({ track, id: i });
      }
    }

    const ds = new mp4box.DataStream();
    ds.endianness = mp4box.DataStream.BIG_ENDIAN;

    for (let i = sendedBoxIdx; i < boxes.length; i++) {
      boxes[i].write(ds);
      delete boxes[i];
    }
    // 释放引用,避免内存泄露
    tracks.forEach(({ track, id }) => {
      file.releaseUsedSamples(id, track.samples.length);
      track.samples = [];
    });
    file.mdats = [];
    file.moofs = [];

    sendedBoxIdx = boxes.length;
    return new Uint8Array(ds.buffer);
  };

  let stoped = false;
  let canceled = false;
  let exit: TCleanFn | null = null;
  const stream = new ReadableStream({
    start(ctrl) {
      timerId = self.setInterval(() => {
        const d = deltaBuf();
        if (d != null && !canceled) ctrl.enqueue(d);
      }, timeSlice);

      exit = () => {
        clearInterval(timerId);
        file.flush();
        const d = deltaBuf();
        if (d != null && !canceled) ctrl.enqueue(d);

        if (!canceled) ctrl.close();
      };

      // 安全起见,检测如果start触发时已经 stoped
      if (stoped) exit();
    },
    cancel() {
      canceled = true;
      clearInterval(timerId);
      onCancel?.();
    },
  });

  return {
    stream,
    stop: () => {
      if (stoped) return;
      stoped = true;
      exit?.();
    },
  };
}

以上步骤,就是在浏览器中创建视频文件的全过程。

在 WebCodecs 之前,前端开发者只能在及其有限的场景使用 ffmpeg.wasm、MediaRecorder 创建视频文件。
现在利用 WebCodecs 则可以快速创建视频文件,并进行非常细致的帧控制,为多样的产品功能提供底层技术支持。

# WebAV 生成视频示例

整个过程的原理不算难,文章的前两张图基本概括了,如果从零开始实现,还是有非常多的细节需要处理,以及更深入地学习一些 mp4 文件相关知识。

你可以略过细节,使用 @webav/av-cliper 提供的工具函数 recodemux 、 file2stream 来快速创建视频文件。

以下是从 canvas 创建视频的示例

import { recodemux, file2stream } from '@webav/av-cliper'

const muxer = recodemux({
  video: {
    width: 1280,
    height: 720,
    expectFPS: 30
  },
  // 后续文章介绍如何处理音频数据
  audio: null
})

let timeoffset = 0
let lastTime = performance.now()
setInterval(() => {
  const duration = (performance.now() - lastTime) * 1000
  muxer.encodeVideo(video
    new VideoFrame(canvas, {
      // 这一帧画面,持续 33ms,duration 单位 μs
      duration,
      timestamp: timeoffset
    })
  )
  timeoffset += duration
}, 33)

const { stream } = file2stream(muxer.mp4file, 500)
// upload or write stream

体验使用不同素材创建视频 DEMO (opens new window); 查看 recodemux 详细用例 (opens new window)

# 附录