使用webCodec完成视频播放器(1)

概要

本篇主要完成视频解封装 -> 视频数据解码 -> canvas播放流程。

整个流程大体如下:

流程及相关代码介绍

mp4解封装

解封装主要就是将视频容器中对应的视频轨道,音频轨道分离开来,之后在用对应的处理器处理。

这边使用的是专门处理mp4视频的工具: mp4Box.js

代码如下:

js 复制代码
const videoUrl = "video/h264_1.mp4";
function queryVideo() {
    // fetch到的是mp4刻度流,包含视频,音频等多个track
    fetch(videoUrl).then((res: Response) => res.arrayBuffer()).then((buffer: BoxArrayBuffer) => {
        buffer.fileStart = 0;
        mp4boxfile.appendBuffer(buffer);
        mp4boxfile.flush();
    })
}
function initMP4Box() {
    mp4boxfile = MP4Box.createFile();

    mp4boxfile.onMoovStart = function() {
        console.log("Moov box start");
    };

    mp4boxfile.onReady = (info) => {
        videoTrack = info.videoTracks?.[0];
        if(!videoTrack) {
            console.warn("获取视频轨道失败");
            return
        }
        mp4boxfile.setExtractionOptions(videoTrack.id, "video", {
            // 每次onSamples函数接收到的samples个数
            nbSamples: 100
        });
        mp4boxfile.start();
    }

    mp4boxfile.onSamples = (trackId, ref, samples) => {
        if (videoTrack.id === trackId) {
            sampleQueueNum += samples.length;
            if(sampleQueueNum >= maxSampleNum) {
                mp4boxfile.stop();
            }

            countSample += samples.length;

            for (const sample of samples) {
                // 是否为关键帧
                const type = sample.is_sync ? 'key' : 'delta';

                const chunk = new (window as any).EncodedVideoChunk({
                    type,
                    timestamp: sample.cts,
                    duration: sample.duration,
                    data: sample.data
                });

                videoDecoder.decode(chunk);
            }

            if (countSample === videoTrack.nb_samples) {
                videoDecoder.flush();
            }
        }
    }
}

完整的api可以浏览mp4Box的官网,这里简单介绍下:

  • appendBuffer:将视频buffer传入Mp4Box对象,可以多次导入(适合视频很大的时候分段加载解析)
  • onReady回调函数: 视频头中的数据被解析完之后触发。这时候可以得到视频的codec,width,height,track等视频的关键描述信息
  • setExtractionOptions: 开始解析对应的轨道数据
  • onSamples回调函数: 解析等到数据后,进入该回调。这时候就可以拿到sample数据了。这里每个sample当一帧去处理的。

上述步骤便完成了fetch到的视频流EncodedVideoChunk的处理

视频数据解码

使用webCodec解码视频流,主要就是通过webCodecEncodedVideoChunk解码为VideoFrame

关键代码如下:

js 复制代码
function initVideoDecode(track: any = videoTrack) {
    videoDecoder = new (window as any).VideoDecoder({
        output: (videoFrame) => {
            createImageBitmap(videoFrame).then((img) => {
                videoFrames.push({
                    img,
                    duration: videoFrame.duration,
                    timestamp: videoFrame.timestamp
                });
                videoFrame.close();
                if(!isPlay) { isPlay = true; draw(); }
            });
            console.log(sampleQueueNum)
            sampleQueueNum --;
            if(sampleQueueNum < maxSampleNum) {
                mp4boxfile.start();
            }
        },
        error: (err) => {
            console.error('videoDecoder错误:', err);
        }
    });
    videoDecoder.configure({
        codec: track.codec,
        codeWidth: track.track_width,
        codeHeight: track.track_height,
        description: getExtradata()
    })
}

// 这个是额外的处理方法,不需要关心里面的细节
function getExtradata() {
    const { DataStream } = MP4Box;
    const validEntryList = mp4boxfile.moov.traks.filter(item => {
        const entry = item.mdia.minf.stbl.stsd.entries[0];
        return entry.avcC ?? entry.hvcC ?? entry.vpcC;
    })
    // 生成VideoDecoder.configure需要的description信息
    const entry = validEntryList[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)
         // slice()方法的作用是移除moov box的header信息
        return new Uint8Array(stream.buffer.slice(8))
    }
};

其实就是维护一个解码队列,使得VideoDecoder解码和onSamples分析视频获取sample同时进行。

这个方法完成后,我们就会得到一个VideoFrame数组。

canvas播放

关键代码如下:

js 复制代码
let index = 0;
function draw() {
    const { img, timestamp, duration } = videoFrames[index];
    const canvasDom = document.getElementById("canvas") as HTMLCanvasElement;
    const context = canvasDom.getContext("2d");
    // 清除画布
    context.clearRect(0, 0, canvasDom.width, canvasDom.height);

    context.drawImage(img, 0, 0, canvasDom.width, canvasDom.height);

    // 开始下一帧绘制
    index++;

    if (index === videoFrames.length) {
        // 重新开始
        index = 0;
    }
    setTimeout(draw, duration);
}

这里其实就是很简单的canvas动画绘制的逻辑。用我们上面得到的VideoFrame数组,做一个循环绘制就行了。

需要注意的是VideoFrame.duration参数,api上说表示这一帧展示的毫秒时间,但是发现不同的视频,duration代表的时间长短不一致,需要用timeout辅助处理。

最终效果

最终便可完成视频在画布上的渲染:

思考

这个效果还不如直接用浏览器的video标签呢。这样大张旗鼓有什么好处嘛?

  1. 倍速播放 : 非常简单,只需要控制setTimeout的间隔时间就行。(原生video中也可以用playbackRate)
  2. 截图 :都拿到视频帧了,直接createObjectURL美滋滋。 (原生video中,可以用MediaStreamTrackProcessor,比较麻烦)
  3. 拖拽进度条: 虽然实现起来很麻烦,但是自由度和播放效果可能要比原生video好很多。
  4. 视频合成: 虽然原生video做不了,但是可以用透明gif图层等其他方式吧
  5. 解码能力webCodec正常情况应该是和原生video标签解码能力类似的.
  6. 直播: 这个应该是比较关键的点了,原生video是没法播放flv,hls之类的直播流的。

参考

  1. www.zhangxinxu.com/wordpress/2...
  2. zhuanlan.zhihu.com/p/654997285
  3. zhuanlan.zhihu.com/p/637337464
相关推荐
腾讯TNTWeb前端团队3 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰6 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪6 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪7 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy7 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom8 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom8 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom8 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom8 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom8 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试