如果你曾经尝试在浏览器中处理视频,比如使用视频编辑或流媒体应用程序,那么你只能选择在服务器上进行处理(这种方式成本较高),或者使用ffmpeg.js(使用起来相对繁琐)。而如今,有了WebCodecs API,就有了一种更好的解决方案。

WebCodecs是一种相对较新的API,它能够让浏览器应用程序以非常低级别的控制方式高效地处理视频文件。

过去,如果你想开发一个视频编辑应用、直播工具,或者任何需要进行复杂视频处理的程序,你就必须构建原生桌面应用程序。像Canva这样的SaaS工具则是通过服务器端视频处理来规避这一问题的,这种方式虽然能提供更好的用户体验,但实现起来更为复杂且成本也更高。

借助WebCodecs,现在完全可以在浏览器中开发这类应用程序,用户无需下载或安装任何软件,也不需要投入昂贵且复杂的服务器基础设施。

这并非理论上的设想。像Capcut这样的视频编辑工具,在切换到使用WebCodecs和WebAssembly之后,其流量增加了83%[1]。而像Remotion Convert以及Free AI Video Upscaler这样的实用工具(均为开源项目),每天都能处理成千上万的视频文件,而且完全不需要任何服务器成本,也无需用户进行任何安装操作[2]。

Remotion Convert

WebCodecs甚至被用于一些全新的应用场景,比如通过编程方式生成视频文件[3]。

无论你正在开发哪种类型的视频应用程序,了解WebCodecs这一选项都是非常有意义的——它能为你在浏览器中处理视频提供有力支持。

在本次指南中,我们将:

  1. 介绍视频处理的基础知识

  2. 讲解WebCodecs API的用法

  3. 讨论如何通过多路复用与解复用技术来读写视频文件

  4. 自己编写一个视频转换工具,实现webm格式与mp4格式之间的转换,并能对视频进行基本的处理

  5. 探讨一些与视频制作相关的技术问题

  6. 介绍其他相关的学习资源

本文的目的是为前端开发者提供一个实用的学习入口和关于WebCodecs API的详细介绍。它会告诉你这个API是如何工作的,以及你可以用它来做什么。我假设你已经掌握了JavaScript的基础知识,但即使你不是资深开发人员或视频工程师,也可以轻松跟随这些内容进行学习。

在文章的最后,我会推荐一些额外的学习资源和参考资料。在未来的教程中,我会更深入地探讨如何使用WebCodecs来构建视频编辑器或进行直播等功能。不过,这份手册应该已经为你提供了关于WebCodecs的基本认识、它的功能以及如何用它来开发简单应用程序的必要信息。

目录

先决条件

你不需要是视频工程师才能学习这些内容,但你需要掌握以下基础知识:

  • 核心JavaScript知识,包括异步/await以及回调函数

  • 基本的浏览器API,如fetch和DOM

  • 了解File对象的概念,以及HTML中文件输入框的工作原理

  • 对HTML5有一个大致的了解(我们会简要使用它,但不会深入探讨)

学习本手册的前半部分内容时,并不需要预先掌握视频处理、编解码器或媒体API的相关知识。

视频处理基础

在开始讲解WebCodecs之前,我想先确保你们了解什么是编解码器。

视频帧

我想大家应该都知道什么是视频。具有讽刺意味的是,下面的这个“视频”实际上是一个gif文件,但你们应该能理解我的意思吧。

Big Buck Bunny,一个开源视频视频实际上只是一系列图像,这些图像以极快的速度依次被显示出来。每一幅图像被称为“视频帧”,而每个视频帧都对应着一个时间戳。当视频播放器开始播放视频时,它会按照时间戳所指示的时间顺序来显示每一个视频帧。视频帧

视频中的每一帧都是由像素组成的,一个4K视频帧大约包含800万像素(3840×2160 = 8294400像素)。

视频帧由像素构成

实际上,每一个像素都是由红、绿、蓝三种颜色值组成的(也称为RGB值)。

RGB色通道

红、绿、蓝三种颜色值中的每一种都是以8位整数的形式存储的,其数值范围为0到255,这个数值代表着相应的红色、绿色或蓝色成分的强度。

8位整数形式的RGB颜色通道

通过组合红、绿、蓝三种颜色成分的强度,就可以表示出色谱中的任意一种颜色:

RGB颜色值示例

因此,对于每一个像素来说,我们需要3字节的数据:红、绿、蓝三种颜色值各需要1字节数据(1字节等于8位)。所以一个4K视频帧大约会包含25兆字节的数据。

RGB色通道

如果以每秒30帧的速率播放视频,那么一个时长为1小时的4K视频将会占用大约746吉字节的数据。如果你曾经下载过较大的视频文件,或者用手机摄像头拍摄过高清视频,你就会知道视频文件的体积确实可能很大,但也不会真的达到那么大的程度。

实际上,你在YouTube上观看的视频、用手机摄像头录制的视频,或是从互联网上下载的视频文件,其大小通常只有上述数值的约1/100。视频文件之所以能被压缩到这么小的体积,要归功于视频压缩技术——这一系列非常先进的算法能够帮助我们将视频数据量减少大约100倍。

如果没有这种视频压缩技术,即便是使用最先进的高端智能手机,我们也无法录制超过10分钟的视频;同样,也无法通过高端的家庭互联网连接来观看或播放高清视频内容。

尽管现代的设备和互联网连接已经非常先进,但如果没有高效的视频压缩技术,我们就根本无法观看、录制或播放高清视频。

编码解码器

编码解码器其实是一种用于视频压缩的算法的称呼。目前有一些被广泛采用的编码解码器/压缩算法,例如:

  • h264:最为常见的编码解码器。如果你看到一个mp4文件,那么它很可能会使用h264编码解码器。

  • vp9:一种开源编码解码器,被YouTube和视频会议系统广泛使用,通常也会出现在webm文件中。

  • av1:一种新的开源编码解码器,越来越多的平台开始采用它,比如YouTube和Netflix。

这些算法的具体工作原理非常复杂,超出了本手册的讲解范围。但从宏观上来说,这些算法主要是通过以下两种方式来压缩视频的:

去除细节信息

所有这些算法都会使用一种称为“离散余弦变换”的技术来“去除细节信息”。当你从视频帧中去除这些细节时,视频帧就会变得看起来更加“块状化”。不过这种技术的效果非常显著,你可以通过这种方式将视频帧的体积压缩大约10倍,而这种压缩带来的变化仍然不会被人眼察觉到。

对于那些感兴趣的人来说,可以观看Computerphile制作的这个视频,了解离散余弦变换算法的具体工作原理。

离散余弦变换算法去除细节信息

编码帧间差异

当你仔细观察一系列连续的视频帧时,你会发现它们在视觉上非常相似,只有其中的一小部分内容会发生变化,这种变化程度取决于视频中存在的运动量。

这些编码解码器/压缩算法运用了复杂的数学原理和计算机视觉技术,来仅对帧与帧之间的差异进行编码处理。

帧间差异

因此,你只需要发送第一帧视频(即关键帧),而对于后续的帧,你可以只发送它们与前一帧之间的差异信息,也就是所谓的差分帧,这样就可以重新构建出完整的视频帧了。

关键帧与差分帧

在实际应用中,对于时长为一小时的视频来说,我们并不会只编码第一帧然后存储数百万个差分帧。相反,算法会每隔大约60帧就其中的一帧进行编码,将其作为关键帧保存,而接下来的59帧则会被编码为差分帧。

这种技术同样非常有效,它能够进一步将视频数据量减少大约10倍。了解关键帧差分帧之间的区别,是理解这些压缩算法工作原理时必须掌握的知识点之一。

在这些压缩算法中,还涉及许多其他细节和压缩技术,但这些内容超出了入门文章的讨论范围。

编码与解码

要想让视频压缩技术发挥作用,我们就必须既能对视频进行压缩(将原始视频转换为压缩后的二进制数据),也能对压缩后的视频进行解压(将压缩的二进制数据重新转换回原始的视频帧)。

将原始视频帧转换为压缩后的二进制数据被称为编码,而将压缩后的二进制数据重新转换回原始视频帧则被称为解码。“codec”这个词其实就是“编码/解码”的缩写。

VideoEncoder and VideoDecoder

从实际开发者的角度来看,你并不需要了解这些编解码器的具体工作原理,但你需要知道以下几点:

  1. 存在多种不同的视频编解码器,比如vp9av1等。

  2. 当你使用某种编解码器对视频进行编码时,你需要一个能够支持该编解码器的视频播放器来播放这些视频。

  3. 对视频进行编码所需的计算资源远远多于解压视频,因此,在低端手机上播放4K视频是没有问题的,但用这类手机对4K视频进行编码会变得非常缓慢。

  4. 大多数消费类设备(手机、笔记本电脑)都配备了专门用于视频编码/解码的芯片,这使得这些操作的速度远快于在普通CPU上通过软件程序来执行。这种现象被称为硬件加速

实际上,目前被广泛使用的视频编解码器数量并不多,因为整个行业需要统一相关标准,这样才能确保在iPhone上录制的视频能够在Windows设备上正常播放。

容器格式

大多数人可能没有听说过h264vp9这些编解码器。当你想到视频文件时,通常会想到MP4或MKV这类文件格式。这些格式确实也与视频存储相关,但它们属于另一种被称为“容器格式”的概念。

一个视频文件通常包含编码后的音频、编码后的视频数据以及关于该视频文件的元数据。像MP4这样的文件格式专门规定了用于存储编码音频/视频数据及元数据的结构规范。

Video Container

视频压缩软件会根据相应的文件格式规范,将编码后的音频/视频数据及元数据存储到文件中。这个过程被称为多路复用

同样地,视频播放器也会依据文件格式规范来读取元数据并提取编码后的音频/视频数据。这个过程被称为多路分离

在压缩视频文件时,你需要按照一定的顺序先对它进行编码,然后再对其进行复用。这两个步骤属于压缩过程中的不同阶段。同样地,在播放视频文件时,你也需要首先对文件进行解复用,之后再对其進行解码,同样需要按照这个顺序来进行操作。

当视频播放器打开一个mp4文件时,其处理流程如下:

  • 这个文件的扩展名是.mp4,因此它肯定是一个mp4文件。我会先加载用于解析mp4文件的库,然后开始解析这个文件。

  • 解析成功了,我现在得到了该文件的元数据,也知道了从文件中的哪些位置可以获取编码后的音频和视频数据。

  • 接下来,我会开始提取第一个编码后的视频帧,对其进行解码,然后将解码后的视频帧显示给用户。

如果视频播放器出现“视频文件损坏”的提示,那很可能是因为该视频文件不符合规定的格式规范,在尝试解析或分离视频数据时出现了错误。

什么是WebCodecs?

既然我们已经了解了编码解码技术,那么接下来就来看看如何将这些技术应用到网页上吧。

WebCodecs = Web + Codecs

WebCodecs是一种API,它允许前端开发者高效地在浏览器中利用硬件加速对视频进行编码和解码,并且能够实现非常细粒度的控制(甚至可以逐帧进行编码/解码操作)。

硬件加速功能非常重要,因为你自己无法通过其他方式来实现类似的编码解码功能。WebCodecs直接提供了对专用硬件的访问权限,因此其性能可以与桌面视频应用程序相媲美。

在WebCodecs出现之前

我们有必要花一点时间来了解为什么需要WebCodecs。在WebCodecs API诞生之前,浏览器中其实有多种其他方法可以用来处理视频相关操作。

  • HTMLVideoElement:你可以使用这个元素来解码视频文件。它使用起来很方便,但缺乏对视频帧的细粒度控制。你所能做的只有设置‘video.currrentTime’属性并等待视频播放到指定位置,但这往往会导致部分帧丢失。

  • Media Recorder API:这个API基本上允许你对任何canvas元素或视频流进行“屏幕录制”。虽然它可以实现这一功能,但实际上它的使用效果相当于使用Adobe Premiere Pro进行屏幕录制,而不是直接对视频进行处理。在编辑视频时,你同样无法对视频帧进行细粒度控制,而且只能以实时速度处理视频。

  • FFMPEG.js:这是流行视频处理工具ffmpeg的浏览器版本。过去有很多工具都使用过它,但由于它不支持硬件加速,因此其运行速度远低于WebCodecs。此外,由于FFmpeg.js是基于WebAssembly运行的,因此对于体积超过100MB的视频文件来说,使用它也会遇到很多问题。

WebCodecs是在2021年开发并发布的,它的目的是实现低级别的、由硬件加速的视频解码与编码功能。对于那些现有的API无法有效满足需求的高性能流媒体服务及视频编辑应用来说,WebCodecs无疑是一个非常实用的工具。

核心API

WebCodecs的核心API包括两种新的“数据类型”:VideoFrameEncodedVideoChunk>,以及VideoEncoderVideoDecoder接口。

VideoFrame

JavaScript中的VideoFrame对象概念上既包含了像素数据,也包含了与该视频帧相关的元数据。

VideoFrame对象

实际上,只要包含了相应的元数据,你就可以从任何图像来源创建一个新的VideoFrame对象:

const bitmapFrame = new VideoFrame(imgBitmap, {timestamp: 0});

const imageFrame = new VideoFrame(htmlImageEl, {timestamp: 0});

const videoFrame = new VideoFrame(htmlVideoEl, {timestamp: 0});

const canvasFrame = new VideoFrame(canvasEl, {timestamp: 0});

例如,在一个视频编辑应用中,你通常会先在画布上对每一帧图像进行编辑操作,然后再从画布中获取这些VideoFrame》对象。

你也可以使用Canvas 2D渲染上下文VideoFrame对象绘制到画布上:

ctx.drawImage(frame, 0, 0);

在浏览器中渲染或播放视频时,通常会采用这种方法。

EncodedVideoChunk

EncodedVideoChunk》其实就是VideoFrame的压缩版本,它既包含了二进制数据,也保留了与该视频帧相同的元数据。

EncodedVideoChunk

通常,你会从某个库中获取EncodedVideoChunks》,而这些库会从File对象中提取这些数据。

import { getVideoChunks } from 'webcodecs-utils'

const chunks = > await getVideoChunks( file);

或者,这些EncodedVideoChunks》也是VideoEncoder对象处理后的输出结果。

对于EncodedVideoChunks>来说,其实并没有太多有用的用途——它们只不过是从文件中读取、写入文件或通过互联网传输的二进制数据而已。

视频流的编码与解码过程

EncodedVideoChunk的优点在于,它的体积大约只有原始视频数据的1/100,因此在进行流媒体传输或文件存储时,使用EncodedVideoChunks》会比原始视频数据更高效。

视频编码器

视频编码器会将视频帧对象转换为编码视频块对象。

视频编码器

其核心API大致如下:你需要定义一个回调函数,以便视频编码器能够通过该函数返回编码视频块对象。

const encoder = new VideoEncoder({
    output: function(chunk: EncodedVideoChunk, meta: any){
        // 对这些编码视频块进行相应的处理
    },
    error: function(e: any)=> console.warn(e);
});

需要注意的是,这是一个异步过程,而且不属于典型的异步流程。你不能将其视为针对每一帧进行的操作。

// 这种写法是错误的
const frame = await encoder.encode(chunk);

这是因为视频编码在底层的工作原理就是这样的。因此,你必须接受这样一个事实:输出结果是通过回调函数来传递的,只有当回调函数被调用时,你才能得到这些输出结果。

一旦你定义好了编码器,就可以根据自己的需求配置视频编码器,比如选择合适的编解码器类型,以及设置宽度、高度、帧率等参数。

encoder.configure({
    'codec': 'vp9.00.10.08.00', // 关于编解码器的配置细节我们稍后会介绍
     width: 1280,
     height: 720,
     bitrate: 1000000 // 即1 MBPS,
     framerate: 25
});

之后你就可以开始对视频帧进行编码了。在这里,我们假设已经拥有了视频帧对象,并且会将每60帧中的第1帧设置为关键帧

for (let i=0; i < frames.length; frames++) {
    encoder.encode(frames[i], {keyFrame: i % 60 === 0});
}

视频解码器

视频解码器的功能正好与视频编码器相反,它会将编码视频块对象转换回视频帧对象。

视频解码器

下面是一个简化示例,展示了如何设置视频解码器。首先,从视频文件中提取编码视频块对象以及解码器配置信息;在解码时,这些配置信息是从文件中读取而来的。

import { demuxVideo } from 'webcodecs-utils';

const { chunks, config} = await demuxVideo( file);

接下来,我们通过指定一个回调函数来设置视频解码器,以便在生成视频帧对象时能够接收到这些对象,并使用之前提取的配置信息对解码器进行配置。

const decoder = new VideoDecoder({
    output: function(frame: VideoFrame){
        // 对这个视频帧进行相应的处理
    },
    error: function(e: any)=> console.warn(e);
});

decoder.configure(config)

VideoEncoder一样,它也会通过回调函数返回帧数据。这样我们终于可以开始解码这些数据块了。

for (const chunk of chunks){
    decoder.decode(chunk);
}

整体归纳

从根本上来说,WebCodecs API主要由两种数据类型EncodedVideoChunk, VideoFrame,以及负责在这两种数据类型之间进行转换的VideoEncoderVideoDecoder接口组成。

WebCodecs的核心架构

需要记住的是,WebCodecs API本身并不直接处理视频文件。它仅仅负责编码和解码操作,而EncodedVideoChunk对象所表示的仅仅是二进制数据而已。

读取或写入视频文件属于另外一系列技术流程,被称为“多路复用/解多路复用”。

多路复用与解多路复用

如果要向视频文件中写入数据,就需要先对视频进行“多路复用”操作;而要播放视频文件,则需要先对其进行“解多路复用”操作。这些过程都需要遵循视频容器的文件格式,对视频文件进行解析(在解多路复用的情况下),或者将编码后的视频数据放置到目标文件中的正确位置(在多路复用的情况下)。

WebCodecs API并不包含多路复用与解多路复用的功能,因此你需要使用其他专门的库来处理这些操作。

解多路复用

要在浏览器中播放视频,我们必须先对视频进行“解多路复用”,然后再对其进行解码,这个顺序是固定的。

解多路复用与解码过程

有很多库可以用来解多路复用视频文件,比如MediaBunnyweb-demuxer。为了方便讲解,我在webcodecs-utils包中编写了一个简化的封装代码,使得解多路复用操作只需两行代码即可完成:

import { demuxVideo } from 'webcodecs-utils'
const { chunks, config} = await demuxVideo(file);

需要注意的是,这种做法会将整个视频文件读入内存,因此在实际应用中并不推荐使用。不过,它对于演示WebCodecs的基本功能来说确实非常有用。

下面的代码示例会接收一个视频文件,对其进行解多路复用处理,然后将解码后的帧数据绘制到画布上。在这里,我们是通过回调函数获取帧数据,并直接在回调函数中执行绘图操作。

import { demuxVideo } from 'webcodecs-utils'

async function playFile(file: File){

    const { chunks, config} = await demuxVideo(file);
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    const decoder = new VideoDecoder({
        output(frame: VideoFrame) {
            ctx.drawImage(frame, 0, 0);
            frame.close()
        },
        error(e) {}
    });

    decoder.configure(config);

    for (const chunk of chunks){
        decoder.decode(chunk)
    }

}

以下是我们用于播放实际视频的极其简化的演示版本:

如果要查看一个更为“规范”的解复用示例,那么使用MediaBunny进行解复用的过程就是这样的——你可以通过迭代的方式提取出所需的视频数据块。

import { EncodedPacketSink, Input, ALL_FORMATS, BlobSource } from 'mediabunny';

const input = new Input({
  formats: ALL_FORMATS,
  source: new BlobSource( file),
});

const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);

for await (const packet of sink.packets()) {
  const chunk =  packet.toEncodedVideoChunk();
}

视频的复用与编码

要创建一个视频文件,不仅需要对其进行编码(使用VideoEncoder),还需要对编码后的数据块进行复用。这意味着要将这些编码好的数据块放置在目标输出文件中的正确位置。

视频的复用与编码过程

同样,你需要一个专门用于视频复用的库(例如MediaBunny),但为了演示 purposes,我创建了一个非常简单的封装类。在这里,我们定义了一个名为ExampleMuxer的简单工具。

import { ExampleMuxer } from 'webcodecs-utils'

const muxer = new ExampleMuxer('video');

for (const chunk of encodedChunks){
    muxer.addChunk(chunk);
}

const outputBlob = await muxer.finish();

作为一个完整的编码+复用演示示例,我们将创建一个编码器,并设置它在接收到编码数据块后立即对其进行复用处理。

const encoder = new VideoEncoder({
    output: function(chunk, meta){
        muxer.addChunk(chunk, meta);
    },
    error: function(e){}
});

encoder.configure({
    'codec': 'avc1.4d0034', // 我们稍后会详细解释这个参数
     width: 1280,
     height: 720,
     bitrate: 1000000 // 即1 MBPS,
     framerate: 25
});

接下来,我们会创建一个canvas动画效果,通过它在屏幕上显示当前的帧数,以此来证明整个系统能够正常工作。

const canvas = new OffscreenCanvas(640, 360);
const ctx = canvas.getContext('2d');
const TOTAL_FRAMES=300;
let frameNumber = 0;
let chunksMuxed = 0;
const fps = 30;


function renderFrame(){
    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = 'white';
    ctx.font = `bold ${Math.min(canvas.width / 10, 72)}px Arial`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`当前帧数:${frameNumber}`, canvas.width / 2, canvas.height / 2);
}

最后,我们将创建编码循环,该循环会绘制当前帧,然后对其进行编码。


let flushed = false;

async function encodeLoop(){

    renderFrame();

    const frame = new VideoFrame(canvas, {timestamp: frameNumber/fps*1e6});
    encoder.encode(frame, {keyFrame: frameNumber %60 ===0});
    frame.close();

    frameNumber++;

    if(frameNumber === TOTAL_FRAMES) {
        if (!flushed) encoder.flush();
    }
    else return requestAnimationFrame(encodeLoop);
}

将所有这些步骤结合起来,你就可以将canvas动画编码成具有帧级精确度的视频文件。

你可以下载这个视频,使用任何视频检测工具来确认其中是否包含了所有的帧。

具有帧级精确度的视频

这就是WebCodecs与其他网络API之间的一个关键区别。比如MediaRecorder也可以编码视频,但它不具备帧级精确度。WebCodecs能够确保你可以控制每一帧的内容,并保证它们的一致性。

最后,使用MediaBunny进行完整的多路复用操作的示例如下:

import {
  EncodedPacket,
  EncodedVideoPacketSource,
  BufferTarget,
  Mp4OutputFormat,
  Output
} from 'mediabunny';

async function muxChunks(chunks: EncodedVideoChunk[]): Promise {

    const output = new Output({
        format: new Mp4OutputFormat(),
        target: new BufferTarget(),
    });

    const source = new EncodedVideoPacketSource('avc');
    output.addVideoTrack(source);

    await output.start();

    for (const chunk of chunks){
        source.add(EncodedPacket.fromEncodedChunk(chunk))
    }

    await output.finalize();
    const buffer =  output.target.buffer;
    return new Blob([buffer], { type: 'video/mp4' });
}

构建视频转换工具

现在我们已经了解了WebCodecs以及多路复用的基本概念,接下来我们将着手开发一个实用的应用程序——视频转换工具。使用这个工具,我们可以将mp4格式的视频转换为webm格式,同时还可以进行一些基本的操作,比如调整视频的大小或翻转视频画面。

转码

在调整视频大小或翻转画面之前,我们首先需要了解如何对视频进行解码,然后再将其编码成新的格式。这个过程就被称为“转码”。

要对视频进行转码,我们需要构建一个包含以下处理步骤的流程:

  • 解复用:从视频文件中读取编码后的视频数据块

  • 解码:将编码后的视频数据块转换为视频帧

  • 编码:将视频帧重新编码为新的编码后的视频数据块

  • 复用:将编码后的视频数据块写入新的视频文件中

我们的转码流程大致如下所示:

转码流程图

利用本文迄今为止介绍的所有内容,我们确实可以仅使用视频编码器视频解码器来构建一个可正常运行的演示示例。但是,这样一来状态管理以及帧的追踪就会变得复杂且容易出错。

我们将再添加一层抽象层,即使用流API,这样我们的转码流程就会变成如下所示。这一机制与我们对于转码流程的理解完全吻合,同时也能简化很多细节,比如状态管理的问题。

const transcodePipeline = demuxerReader
    .pipeThrough(new VideoDecoderStream(videoDecoderConfig))
    .pipeThrough(new VideoEncoderStream(videoEncoderConfig))
    .pipeTo(createMuxerWriter(muxer));

await transcodePipeline;

为此,我们需要为视频解码器视频编码器分别创建一个TransformStream对象。

class VideoDecoderStream extends TransformStream<{ chunk: EncodedVideoChunk; index: number }, { frame: VideoFrame; index: number }>> {
  constructor(config: VideoDecoderConfig) {
    let pendingIndices: number[] = [];
    super(
      {
        start(controller) {
          decoder = new VideoDecoder({
            output: (frame) => {
              const index = pendingIndices.shift()!;
              controller.enqueue({ frame, index });
            },
            error: (e) => controller.error(e),
          });

          decoder.configure(config);
        },

        async transform(item, controller) {
          pendingIndices.push(item.index);
          decoder.decode(item.chunk);
        },

        async flush(controller) {
          await decoder.flush();
          if decoder.state !== 'closed' decoder.close();
        },
      }
    );
  }
}

我不会在这里详细列出全部代码,但我已经将这些辅助函数打包到了webcodecs-utils包中,使用时可以这样导入:

import {
  SimpleDemuxer,
  VideoDecodeStream,
  VideoEncodeStream,
  SimpleMuxer,
} from "webcodecs-utils";

那么,我们用来对文件进行转码的代码最终就会变成这样:

const demuxer = new SimpleDemuxer(videoFile);
await demuxer.load();
const decoderConfig = await demuxer.getVideoDecoderConfig();

const encoderConfig = {/*我们最终会决定使用什么配置*/);

// 设置多路复用器
const muxer = new SimpleMuxer({ video: "avc" });

// 构建升级处理流程
await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(new VideoEncodeStreamencoderConfig))
  .pipeTo(muxer.videoSink());

// 获取最终输出结果
const blob = await muxer.finalize();

在这个演示中,为了使转码功能能够正常工作,我们将下载一个预先制作好的文件,并且会提供一个开关选项,让用户可以选择输出mp4格式的视频(使用vp9编码格式)。

我们将使用avc1.4d0034作为h264编码格式的字符串,而vp09.00.40.08.00则用于表示vp9编码格式。

下面是在CodePen上实现的简单转码演示:

视频转换操作

如果我们对视频进行翻转、裁剪、旋转或调整大小等操作,就无法仅使用纯VideoFrame对象来完成这些任务。

实现这些功能的最简单方法就是使用Canvas元素。我们可以通过2D Canvas上下文来操作原始视频帧,并将其绘制到Canvas上。

const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');

// 进行转换操作非常简单
ctx.drawImage(sourceFrame, 0, 0);

之后,我们可以将这个Canvas作为输出视频帧的源图像。

const outFrame = new VideoFrame(canvas, {timestamp: sourceFrame.timestamp});

如果要调整视频的大小,首先需要将Canvas的尺寸设置为所需的输出高度和宽度。

const canvas = new OffscreenCanvas(outputWidth, outputHeight);
const ctx = canvas.getContext('2d');

// 将sourceFrame调整为适合输出尺寸的大小
ctx.drawImage(sourceFrame, 0, 0, outputWidth, outputHeight);

如果想要使用2D Canvas实现水平翻转效果,可以按照以下方式操作:

ctx.scale(-1, 1);
ctx.translate(-outputWidth, 0);
ctx.drawImage(sourceFrame, 0, 0, outputWidth, outputHeight);

你可以编写一个完整的渲染函数,将上述所有转换操作整合到一起,其代码结构大致如下:

function render(videoFrame, outW, outH, flipped) {

  canvas.width = outW;
  canvas.height = outH;

  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(videoFrame, 0, 0, outW, outH);

}

以下是一个交互式演示,展示了这些变换效果的实际表现:

变换处理流程

为了实现这些变换效果,我们需要调整我们的处理流程,添加相应的变换步骤。该步骤会接收一个VideoFrame对象,对其应用相应的变换操作,然后返回经过处理的帧。

包含变换操作的转码流程

在`webcodecs-utils`包中,有一个专门用于实现这一功能的`VideoProcessStream`对象。该对象接受一个异步函数作为参数,这个函数会接收一个VideoFrame对象,并返回另一个VideoFrame对象:

import { VideoProcessStream} from "webcodecs-utils";
 
new VideoProcessStream(async (frame) => {
      // 对帧应用变换操作
      return processedFrame;
    }),

因此,要应用我们的变换规则,我们可以这样设置代码:

import { VideoProcessStream} from "webcodecs-utils";
 
const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d');

const processStream = new VideoProcessStream(async (frame) => {
  
  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(frame, 0, 0, outW, outH);

  return new VideoFrame(canvas, {timestamp: frame.timestamp});
});

最终,我们的完整处理流程如下:

const demuxer = new SimpleDemuxer(videoFile);
await demuxer.load();
const decoderConfig = await demuxer.getVideoDecoderConfig();

const encoderConfig = {/*根据需要配置*/);

// 设置多路复用器
const muxer = new SimpleMuxer({ video: "avc" });

// 构建升级处理流程
await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(processStream) // 新添加的步骤
  .pipeThrough(new VideoEncodeStreamencoderConfig))
  .pipeTo(muxer.videoSink());

// 获取最终输出结果
const blob = await muxer.finalize();

以下是一个包含完整处理流程的实际演示视频:

完整演示

对于这个完整的工具来说,我们需要进行一些关键性的修改:

  • 用户可以上传自己的视频文件。

  • 我们会通过提取视频中的某帧来预览变换效果。

  • 我们还会添加进度显示功能。

对于输入数据来说,处理起来非常简单:

<input type="file" onchange="handler(event)" />

对于帧预览功能,我们可以使用 WebCodecs 来生成预览图像,但由于预览并不需要达到帧级别的精确度或高性能,因此直接使用 HTML5 的 VideoElement 从源文件中提取视频帧会更为方便。

async function getFirstFrame(file) {
  const video = document.createElement("video");
  video.src = URL.createObjectURL(file);
  video.muted = true;

  await new Promise((resolve) => video.addEventListener("loadeddata", resolve, { once: true }));
  video.currentTime = 0;
  await new Promise((resolve) => video.addEventListener("seeked", resolve, { once: true });

  return new VideoFrame(video, {timestamp: 0});
}

最后,我们可以在处理函数中通过将帧的时间戳除以视频的总时长来计算处理进度。

const {duration} = await demuxer.getMediaInfo();

const processStream = new VideoProcessStream(async (frame) => {
  
  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(frame, 0, 0, outW, outH);

   // 帧的时间戳以微秒为单位,视频时长以秒为单位
  const progress = frame.timestamp/(duration*1e6); 

  return new VideoFrame(canvas, {timestamp: frame.timestamp});

});

将所有这些组件整合起来,我们最终就能制作出一个功能完备的视频转换工具:

就这样!我们利用 WebCodecs 的解码、编码以及 Canvas 变换功能,成功构建了一个具有实际使用价值的 MVP 系统 🎉。

这个工具与像 Capcut 这样的专业视频编辑软件相比,唯一的区别在于其变换功能的规模和范围而已;不过它们的视频处理逻辑其实是基本相同的。

生产环境中的注意事项

我们能够开发出这样的实用工具固然很棒,但在结束讨论之前,还有一些与生产环境相关的问题需要探讨。

编码格式

在之前的演示中,你们可能注意到了一些像 vp09.00.10.08 这样的编码字符串,但我之前没有详细解释它们的含义。现在我们就来说明一下:

首先,WebCodecs 支持特定的编码格式字符串,比如 vp09.00.10.08,而不仅仅是 vp9 这种通用格式。以下这种写法是无效的:

const codec = VideoEncoder({
    codec: 'vp9', // 这种写法是错误的!
    //...
})

如前所述,在解码视频时,用户实际上并没有选择编码格式的选项——视频已经被预先编码好了,因此我们需要从视频文件中获取相应的编码信息,之前的演示中也展示了这一过程。

上述提到的解码库能够自动识别正确的编解码器字符串,因此你无需为此担心。

const decoderConfig = await demuxer.getVideoDecoderConfig();
// decoderConfig.codec 就是视频所使用的具体编解码器字符串

在编码视频时,你可以自行选择编解码器。虽然有些人非常重视编解码器的选择,但从实际应用的角度来看,以下这些经验法则应该适用于大多数开发者:

  • 如果你的应用程序生成的视频会被用户下载,或者你希望输出mp4格式的文件,那么请选择 h264 编解码器。

  • 如果生成的视频仅用于内部使用,且你可以控制视频的播放方式,并且不关心文件格式,那么可以选择 vp9 与 webm 格式结合使用——这种编解码器是开源的,压缩效果更好,同时也得到了最广泛的支持。

  • 对于大多数应用程序来说,这两种选择已经足够满足需求了。深入研究各种复杂的编解码器选项其实并没有必要。

一旦确定了要使用的编解码器类型,接下来就需要指定具体的编解码器字符串,例如 avc1.42001f

该字符串中的其他数字表示一些特定的编解码器参数,但从开发者的角度来看,这些参数并不是那么重要。如果你追求最大的兼容性,以下就是关于应该使用哪些编解码器字符串的参考信息:

h264(用于 mp4 文件)
vp9(用于 webm 文件)

你也可以使用 webcodecs-utils 包中的 getCodecString 函数来获取所需的编解码器字符串。

import { getCodecString } from 'webcodecs-utils'

const codec_string = getCodecString('vp9', width, height, bitrate)

你可以在这里找到WebCodecs中可使用的各种编解码器及其对应的编码格式列表。

比特率

在指定视频的分辨率和宽度高度之外,还需要确定比特率才能进行编码。

视频压缩算法会在质量与文件大小之间做出权衡:高画质会导致文件体积增大,而低画质则会使文件变小。

以下是不同比特率下1080p视频的质量对比图:

300 kbps

300kbps帧

1 Mbps

1Mbps帧

3 Mbps

3 Mbps帧

10 Mbps

10 Mbps帧

以下是一个便于参考的比特率对照表:

分辨率 30帧/秒时的比特率 60帧/秒时的比特率
4K 13-20 Mbps 20-30 Mbps
1080p 4.5-6 Mbps 6-9 Mbps
720p 2-4 Mbps 3-6 Mbps
480p 1.5-2 Mbps 2-3 Mbps
360p 0.5-1 Mbps 1-1.5 Mbps
240p 300-500 kbps 500-800 kbps

你也可以在自己的应用程序中使用这个函数来快速估算所需的比特率。

function getBitrate(width, height, fps, quality = 'good') {
    const pixels = width * height;

    const qualityFactors = {
      'low': 0.05,
      'good': 0.08,
      'high': 0.10,
      'very-high': 0.15
    };

    const factor = qualityFactors[quality] || qualityFactors['good'];

    // 返回以比特每秒为单位的比特率
    return pixels * fps * factor;
  }

在 `webcodecs-utils` 包中也可以找到相同的函数:

import { getBitrate } from 'webcodecs-utils'

GPU与CPU

大多数用户设备都配备有某种类型的显卡(通常称为集成显卡)。这些专用芯片具有专为视频编码解码以及基本图形处理而优化的硅片架构。

你可能会一听到“GPU”就想到人工智能数据中心和游戏玩家。但就网页应用程序而言,几乎每个人都有 GPU。

这一点非常重要,因为虽然大多数前端开发工作主要依赖 CPU,但 WebCodecs 和视频处理任务却主要由 GPU 完成。

以下是关于各种数据存储位置的简要说明:

数据类型 存储位置
VideoFrame GPU
EncodedVideoChunk CPU
ImageBitmap GPU
ArrayBuffer CPU
File CPU + 硬盘

数据传输会带来性能开销,因此这在内存管理方面也非常重要。

内存

VideoFrame 对象的体积可能相当大——4K 视频的 VideoFrame 对象大小可达 30MB。用户的显卡通常会预留一部分 RAM 作为“视频内存”或“VRAM”,而 VideoFrame 对象就会存储在这些内存中。

如果用户的 RAM 总量为 8GB,那么他们通常会有 2GB 的 VRAM(具体分配量由操作系统决定)。

如果视频数据的体积超过了 VRAM 的容量,应用程序就会崩溃。这意味着对于普通用户来说,如果内存中存储的 4K 视频帧数量超过 67 帧(大约相当于 2 秒长的视频),程序就会崩溃。

VideoFrame 对象的生成时机

每当你创建一个 `new VideoFrame(source)` 对象时,或者通过 `VideoDecoder` 的输出回调函数时,都会生成 VideoFrame 对象。每次有新的帧被生成,内存使用量就会增加。

如何删除 VideoFrame 对象

你不能依赖标准的垃圾回收机制来处理 VideoFrame 对象。当你不再需要这些对象时,必须手动调用它们的 `close()` 方法:

frame.close()

在之前展示的 Streams/Pipeline 代码示例中,实际上每当视频帧在 `VideoProcessStream` 和 `VideoEncodeStream` 接口中被编码完成后,这些帧就会立即被关闭。

Streams 对 WebCodecs 来说还有一个重要的作用,那就是 `highWaterMark` 属性。该属性的默认值为 10。这意味着当你执行以下代码时:

await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(processStream) 
  .pipeThrough(new VideoEncodeStreamencoderConfig))
  .pipeTo(muxer.videoSink());

你需要确保在任何时候,内存中存储的视频帧数量都不超过10个。Streams API允许你指定这一限制,而浏览器本身会负责处理实现这一限制的具体逻辑。

如果你不使用Streams API,那么你就需要自己负责监控内存使用限额以及同时打开的视频帧数量。

更多资源

通过这篇文章,我们了解了视频处理的基础知识,介绍了WebCodecs API的核心概念,并构建了一个简单的视频转换工具的演示版本。这个演示虽然很简单,但却涵盖了API的各个功能模块。我们还讨论了一些与实际开发相关的问题。

这仅仅是一个入门介绍,只是触及了WebCodecs表面的内容。尽管这个API看起来很简单,但要开发出一个真正可用于实际生产的WebCodecs应用程序,还需要进一步深入研究。

如果你想了解更多关于WebCodecs的信息,可以访问MDN,或者阅读WebCodecsFundamentals——这是一本深入讲解WebCodecs的在线教材。

你还可以查看一些经过实际生产环境测试的应用程序的源代码,比如Remotion Convert源代码);这个应用程序与我们讨论过的演示版本非常相似。另外还有Free AI Video Upscaler源代码处理流程),这个项目为这里介绍的设计模式提供了灵感,并被实现在了webcodecs-utils中。

最后需要说明的是,虽然WebCodecs的开发难度看起来有些高,但使用像MediaBunny这样的库,可以大大简化内存管理、文件读写等操作。我在自己开发的实际生产环境中的WebCodecs应用程序中也在使用这个库。

无论你最终是否真的开发出一个完整、可投入实际生产的WebCodecs应用程序,至少你现在已经知道这是一个可行的选择——这种技术相对较新,能够提供更好的用户体验,并且能降低服务器成本。像Capcut和Descript这样的知名视频应用也在越来越多地采用这一技术来提升自身的功能。

Comments are closed.