如何实现一个网页版的剪映(上)本文研究了网页版剪映是如何实现的,并写了简易的 demo,WebCodes 进行编解码,web - 掘金

如何实现一个网页版的剪映(上)本文研究了网页版剪映是如何实现的,并写了简易的demo,WebCodes进行编解码,web - 掘金


  • 如何实现一个网页版的剪映(上)本文研究了网页版剪映是如何实现的,并写了简易的demo,WebCodes进行编解码,web - 掘金

  • https://juejin.cn/post/7444840280850808851

  • 本文研究了网页版剪映是如何实现的,并写了简易的demo,WebCodes进行编解码,webgl做视频转场---
    theme: juejin


    前言

    大学四年,疫情三年。当年在腾讯会议上了一节又一节的课,因此毕设我结合教育的背景,用了janus搭了个课堂直播间

    WebCodecs提供编解码,WebGPU可以

  • 2025-01-01 12:51:32


前言

大学四年,疫情三年。当年在腾讯会议上了一节又一节的课,因此毕设我结合教育的背景,用了janus搭了个课堂直播间

WebCodecs​提供编解码,WebGPU​可以提供更加高效的视频渲染,WebTransport​可以有效改善实时数据传输的延迟,缺少的算法还可以编译现成的C/C++​到WebAssembly​给web使用,因此未来的音视频方向(云端剪辑、云游戏等)一定是百花齐放的。很庆幸上班后还对音视频留有兴趣,于是今年上半年研究了一波剪映,并实现了一个简易版的剪映

由于在写文章的时候,剪映网页版下线了。因此本文的图来自剪映客户端

1 核心功能

image.png

我实现的demo

image.png

剪映PC端

剪映主要分为四个模块:素材管理、视频渲染及播放、视频/素材参数调节、时间轴

还有其他的一些功能:视频模板、AI工具

2 重难点分析

  • 这么多素材,应该存哪(存内存、后端、opfs?)
  • 如何编解码音视频,并且播放对应的帧
  • 如何实现视频转场
  • 视频轨道编辑器编辑器(视频缩略图、音频波形图、拖拽轨道、时间轴缩放等)

解决完以上难点,剩下的就是实现一个类似低代码的图片编辑器

如果我们把图片编辑器看成二维的,那么音视频编辑器就是在这基础上加上时间的维度

因此在编辑器素材数据结构的设计上,可以参考一些低代码的数据结构,再存上什么时间范围内渲染什么素材,就功德圆满了

2-1 解码视频

市面上常见的网页端处理音视频的方案大概有两种:

假如使用ffmpeg解码,需要考虑以下因素

性能​:B站做过相关测试

image.png

来自B站的测试

获取视频信息​:需要通过ffmpeg.run('-i', filePath)​调用,但是结果的输出是通过logger输出文本的,因此还需要写一段解析输出的逻辑

this.ffmpeg.setLogger(logs => {
  const s = logs.message;
  if (s.includes('Duration')) {
    const duration = s.slice(
      s.indexOf('Duration:') + 'Duration: '.length,
      s.indexOf(',')
    );
    const bitRate = s.slice(s.indexOf('bitrate:') + 'bitrate: '.length);
    this.videoInfo.duration = duration;
    this.videoInfo.bitRate = bitRate;
  }
  if (s.includes('Video:') && s.includes('tbr')) {
    console.warn(s);
    const fps = s.match(/, ([\d.]+) fps,/)?.[1] ?? '';
    const wh = s.match(/, (\d+)x(\d+)/);
    const width = wh?.[1] ?? '';
    const height = wh?.[2] ?? '';
    this.videoInfo.fps = fps;
    this.videoInfo.width = width;
    this.videoInfo.height = height;
  }
});

封装ffmpeg命令​:ffmpeg.wasm就是把ffmpeg编译成了wasm,对音视频进行操作还是离不开命令行参数,因此需要熟悉命令行参数并且要封装一些命令工具函数。

commands: ["-i",filePath,"-vf",`fps=${fps}`,"-s",`${size.w}x${size.h}`,`${framePath}${fileName}`];

可能需要封装任务队列​:ffmpeg.wasm不能同时执行多条命令,因此我想要连续执行一些命令需要维护一个任务队列,等上一个任务完成接着执行下一个任务。

维护文件系统​:ffmpeg自己实现了一个文件系统,所有api操作视频都是基于这个文件系统的,例如读取文件系统中的一张图片。

const data = this.ffmpeg.FS('readFile', 'output.jpg');
const url = URL.createObjectURL(
  new Blob([data.buffer], { type: 'image/jpeg' })
);
this.rmFile('output.jpg');

因此,如果通过new多个ffmpeg实例来解决不能同时执行多条命令,还需要考虑输出的文件在哪个实例上、如何跨实例传输文件内容。

资源大小​:这3个东西23M

image.png

而使用浏览器自带的WebCodes只需要熟悉浏览器api即可

认识MP4结构

一个MP4文件里面都有什么

可以通过gpac.github.io/mp4box.js/t… 在线查看MP4文件的结构

image.png

image.png

MP4文件结构

MP4文件由若干个box组成,每个box有类型和长度,box可以接着套box,就像json一样。

一个MP4由下面3大类box组成:

  1. “ftyp” 类型box​:一个MP4文件的开头会有且只有一个 “ftyp” 类型的box(File Type Box),包含关于文件的一些信息:版本、兼容协议等
  2. “moov”类型box​:之后会有且只有一个“moov”类型的box(Movie Box),包含文件媒体的metadata信息:视频的分辨率、帧率、码率,音频的采样率、声道数等
  3. “mdat”类型box​:“mdat”类型的box(Media Data Box),存放实际的媒体数据

其中还有一些重要的子box

  • mvhd记录了时长等信息

image.png

  • stss记录了关键帧的位置

image.png

stss记录了每一帧的播放时间

image.png由mdhd box得timescale=15360,由stss box的sample_deltas=256,把1s分成15360份,每一帧占256份,也就是每帧占0.016s。sample_counts=16440,意思是前16440帧,每一帧的时间是0.016s。

我们可以计算出,这个MP4的fps是60,时长是16440*0.016s=274s=4.5分钟,和ffmpeg的信息一样

ffmpeg -hide_banner -i .\video.mp4image.png

因为stss box是已经存在的,因此,点播场景(类似B站看视频这种)比较适合用MP4格式,可以通过stss box等box计算帧的位置并且很快地跳转

而直播就不行了,因为MP4的这些box要等直播完才能生成,直播更适合用flv

  • stsz记录了每一帧的大小(单位byte)

image.png

在浏览器中,我们可以通过mp4box.js解码MP4文件

import MP4Box from "MP4Box";

async function handleArrayBuffer(buffer) {
  const videoSamples = [];
  const file = MP4Box.createFile();
  const mp4InfoPromise = new Promise((resolve, reject) => {
    // 当“moov”被解析时,即当有关文件的元数据被解析时,会调用回调。
    file.onReady = resolve;
    file.onError = reject;
  });

  buffer.fileStart = 0;
  file.appendBuffer(buffer);

  const mp4Info = await mp4InfoPromise;
  const vTrack = mp4Info.videoTracks[0];
  const vTrackId = vTrack?.id;
  if (vTrackId != null)
    file.setExtractionOptions(vTrackId, "video", { nbSamples: 100 });

  const totalFrameCount = vTrack.nb_samples;
  let sampleFrameCount = 0;

  return new Promise((resolve) => {
    file.onSamples = (id, type, samples) => {
      sampleFrameCount += samples.length;
      videoSamples.push(
        ...samples.map((s) => {
          // 格式化时长
          return {
            ...s,
            cts: (s.cts / s.timescale) * 1e6,
            dts: (s.dts / s.timescale) * 1e6,
            duration: (s.duration / s.timescale) * 1e6,
            timescale: 1e6,
          };
        })
      );
      if (totalFrameCount === sampleFrameCount) {
        resolve([videoSamples, file, mp4Info]);
      }
    };
    file.start();
  });
}

export default function App() {
  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const res = await handleArrayBuffer(await file.arrayBuffer());
          console.log(res);
        }}
      />
    </div>
  );
}

image.png

image.png

image.png

这样,我们就解析出了视频的数据

在解析的时候,对duration这些参数进行了操作 s.duration / s.timescale

这是因为 timescale 是文件媒体在1秒时间内的刻度值,可以理解为1秒长度的时间单元数

例如:一个 videoTrack 的 timescale = 600, duration = 30000,那么它的时长是30000/600=50s

一次性解析视频全部帧

import MP4Box from "MP4Box";

async function handleArrayBuffer(buffer) {
  const videoSamples = [];
  const file = MP4Box.createFile();
  const mp4InfoPromise = new Promise((resolve, reject) => {
    // 当“moov”被解析时,即当有关文件的元数据被解析时,会调用回调。
    file.onReady = resolve;
    file.onError = reject;
  });

  buffer.fileStart = 0;
  file.appendBuffer(buffer);

  const mp4Info = await mp4InfoPromise;
  const vTrack = mp4Info.videoTracks[0];
  const vTrackId = vTrack?.id;
  if (vTrackId != null)
    file.setExtractionOptions(vTrackId, "video", { nbSamples: 100 });

  const totalFrameCount = vTrack.nb_samples;
  let sampleFrameCount = 0;

  return new Promise((resolve) => {
    file.onSamples = (id, type, samples) => {
      sampleFrameCount += samples.length;
      videoSamples.push(
        ...samples.map((s) => {
          // 格式化时长
          return {
            ...s,
            cts: (s.cts / s.timescale) * 1e6,
            dts: (s.dts / s.timescale) * 1e6,
            duration: (s.duration / s.timescale) * 1e6,
            timescale: 1e6,
          };
        })
      );
      if (totalFrameCount === sampleFrameCount) {
        resolve([videoSamples, file, mp4Info]);
      }
    };
    file.start();
  });
}
// 生成videoDecoder.config需要的参数
function getExtractFileConfig(mp4File, mp4Info) {
  const vTrack = mp4Info.videoTracks[0];
  let videoDesc;
  // 生成VideoDecoder.configure需要的description信息
  const entry = mp4File.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
  const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
  if (box != null) {
    const stream = new MP4Box.DataStream(
      undefined,
      0,
      MP4Box.DataStream.BIG_ENDIAN
    );
    box.write(stream);
    // slice()方法的作用是移除moov box的header信息
    videoDesc = new Uint8Array(stream.buffer.slice(8));
  }
  const videoDecoderConf = {
    codec: vTrack.codec,
    codedHeight: vTrack.video.height,
    codedWidth: vTrack.video.width,
    description: videoDesc,
  };

  return videoDecoderConf;
}

// 将视频全部帧解析出来,并转化成ImageBitmap存下来
function genFrame(videoSamples, config) {
  const { codedWidth: width, codedHeight: height } = config;
  return new Promise((resolve, reject) => {
    const pngPromises = [];
    let cnt = 0;
    const resolver = async () => {
      const frames = await Promise.all(
        pngPromises.map(async (v) => ({
          ts: v.ts,
          img: await v.img,
        }))
      );
      resolve({
        width,
        height,
        duration: videoSamples[videoSamples.length - 1].dts,
        frames,
      });
    };
    const videoDecoder = new VideoDecoder({
      output: (videoFrame) => {
        cnt++;
        const cvs = new OffscreenCanvas(width, height);
        const ctx = cvs.getContext("2d");
        ctx.drawImage(videoFrame, 0, 0, width, height);
        const bitmap = cvs.transferToImageBitmap();
        // 解码出来的每一帧,用数组把他存下来
        pngPromises.push({
          ts: videoFrame.timestamp,
          img: bitmap,
        });
        // 用完及时close,VideoFrame会占用大量显存
        videoFrame.close();
        // 解码完毕
        if (cnt === videoSamples.length - 1) {
          resolver();
        }
      },
      error: (err) => {
        console.error("videoDecoder错误:", err);
      },
    });
    videoDecoder.configure(config);
    // videoDecoder开始解析每一帧
    videoSamples.forEach((s) => {
      videoDecoder.decode(
        // 将sample转换成EncodedVideoChunk,可以被VideoDecoder进行解码
        new EncodedVideoChunk({
          type: s.is_sync ? "key" : "delta",
          timestamp: (1e6 * s.cts) / s.timescale,
          duration: (1e6 * s.duration) / s.timescale,
          data: s.data,
        })
      );
    });
  });
}
export default function App() {
  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const [videoSamples, mp4File, mp4Info] = await handleArrayBuffer(
            await file.arrayBuffer()
          );
          const { frames } = await genFrame(
            videoSamples.slice(0, 1000),
            getExtractFileConfig(mp4File, mp4Info)
          );
          let i = 0;
          const canvas = document.querySelector("canvas");
          const ctx = canvas.getContext("2d");
          const draw = () => {
            const { img } = frames[i];
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
            i++;
            if (i === frames.length) {
              return;
            }
            setTimeout(draw, 33);
          };
          draw();
        }}
      />
      <canvas></canvas>
    </div>
  );
}

选择一个​小一点的文件​(一共152帧),我们就能播放了

注意:

  1. genFrame函数​会把每一帧都转化成ImageBitmap​存下来,因此只能使用小文件来试,不然会很占内存

  2. 为什么要把VideoFrame​转化成ImageBitmap​而不是直接存下来
    我们改造一下genFrame函数
    实验1,不及时把videoFrame关闭

    const videoDecoder = new VideoDecoder({
      output: (videoFrame) => {
          // videoFrame.close();
          console.count();
      }
    })
    

    结果: 能完全解析全部帧,并且报了个错

    image.png

    实验2,把VideoFrame​存在数组里,不及时把videoFrame关闭

    const videoFrames = [];
    const videoDecoder = new VideoDecoder({
      output: (videoFrame) => {
        videoFrames.push(videoFrame);
        // videoFrame.close();
        console.count();
      }
    })
    

    结果: 只能解析17个帧,然后没有反应了

    image.png

    实验3,把VideoFrame​存在数组里,过一秒才关闭

    const videoFrames = [];
    const videoDecoder = new VideoDecoder({
      output: (videoFrame) => {
        videoFrames.push(videoFrame);
        setTimeout(() => {
           videoFrame.close();
        }, 1000);
        // videoFrame.close();
        console.count();
      }
    })
    

    结果: 每过1s输出17个结果,最后能解析完全部帧

    image.png

    结论: 浏览器只能保存大约17个VideoFrame​对象。因此不能直接把VideoFrame​存下来,需要转化成其他格式

  3. genFrame函数​加一点抽帧的条件限制(每经过X帧抽一次帧)就能实现视频缩略图的功能了。可以将抽出来的帧降低分辨率,压缩画质,存在OPFS

image.png

  1. VideoFrame​要及时close。VideoDecoder​要及时close。不要一次给VideoDecoder​太多帧进行解析,解码器可能会来不及处理(可以通过encodeQueueSize​查看队列中待解码的数量)

认识视频帧

本来这一节可以直接参考 I帧、P帧、B帧、GOP、IDR 和PTS, DTS之间的关系 的,但是为了防止文章失效、或者博客园不干了,还是整理一下吧


RGB和YUV

需要编解码的视频图像一般不使用RGB色彩空间,而是使用一种称为YUV的色彩空间。

在YUV格式中,“Y”代表亮度,也就是我们通常所说的灰度值,它决定了图像的明暗程度。而“U”和“V”则代表色度,它们负责描述图像的色彩和饱和度,帮助我们区分不同的颜色。

它是基于人眼对亮度变化比色度变化更敏感的原理设计的,还实现了​彩色电视和黑白电视的兼容​。

不同于RGB图像一般按像素存储(如RGBRGBRGBRGB),YUV图像一般按平面存储,即将所有的Y放到一起,所有的U放到一起,所有的V放在一起(如YYYYUUUUVVVV),其中每一部分称为一个平面。这种存储方式的一个好处就是,在广播电视中,当接收到一帧图像时,黑白电视机只需要播放Y平面(黑白图像),而忽略代表颜色的U和V平面。当然,彩色电视机则需要播放所有平面的数据。

image.png image.png

因此,YUV格式广泛应用于视频压缩和传输,因为它兼容性更好,并且在保持图像质量的同时,能够有效地减少数据量


在H.264压缩标准中I帧、P帧、B帧用于表示传输的视频画面。

image.png

IPB帧示意图

I帧

即Intra-coded picture(帧内编码图像帧)。I帧表示​关键帧​,你可以理解为这一帧画面的完整保留。解码时只需要本帧数据就可以完成(因为包含完整画面)

I 帧通常是每个 GOP(MPEG 所使用的一种视频压缩技术)的​第一个帧​,经过适度地压缩,做为随机访问的参考点,可以当成图像。

在MPEG编码的过程中,部分视频帧序列压缩成为I帧;部分压缩成P帧;还有部分压缩成B帧。I帧法是帧内压缩法,也称为“关键帧”压缩法。

I帧法是基于离散余弦变换DCT(Discrete Cosine Transform)的压缩技术,这种算法与JPEG压缩算法类似。采用I帧压缩可达到1/6的压缩比而无明显的压缩痕迹。

【​I帧特点​】

  1. 它是一个全帧压缩编码帧。它将全帧图像信息进行JPEG压缩编码及传输
  2. 解码时仅用I帧的数据就可重构完整图像
  3. I帧描述了图像背景和运动主体的详情
  4. I帧不需要参考其他画面而生成
  5. I帧是P帧和B帧的参考帧(其质量直接影响到同组中以后各帧的质量)
  6. I帧是帧组GOP的基础帧(第一帧),在一组中只有一个I帧
  7. I帧不需要考虑运动矢量
  8. I帧所占数据的信息量比较大
P帧

即Predictive-coded Picture(前向预测编码图像帧)。P帧表示的是这一帧跟之前的一个关键帧(或P帧)的差别,解码时需要用之前缓存的画面叠加上本帧定义的差别,生成最终画面。(也就是差别帧,​P帧没有完整画面数据,只有与前一帧的画面差别的数据​)

image.png

P帧示意图

【​P帧特点​】

  1. P帧是I帧后面相隔1~2帧的编码帧
  2. P帧采用运动补偿的方法传送它与前面的I或P帧的差值及运动矢量(预测误差)
  3. 解码时必须将I帧中的预测值与预测误差求和后才能重构完整的P帧图像
  4. P帧属于前向预测的帧间编码。它只参考前面最靠近它的I帧或P帧
  5. P帧可以是其后面P帧的参考帧,也可以是其前后的B帧的参考帧
  6. 由于P帧是参考帧,它可能造成解码错误的扩散
  7. 由于是差值传送,P帧的压缩比较高
B帧

即Bidirectionally predicted picture(双向预测编码图像帧)。B帧是双向差别帧,也就是​B帧记录的是本帧与前后帧的差别​,换言之,要解码B帧,​不仅要取得之前的缓存画面,还要解码之后的画面​,通过前后画面的与本帧数据的叠加取得最终的画面。B帧压缩率高,但是解码时CPU会比较累。

image.png

B帧示意图

【​B帧特点​】

  1. B帧是由前面的I或P帧和后面的P帧来进行预测的
  2. B帧传送的是它与前面的I帧或P帧和后面的P帧之间的预测误差及运动矢量
  3. B帧是双向预测编码帧
  4. B帧压缩比最高,因为它只反映丙参考帧间运动主体的变化情况,预测比较准确
  5. B帧不是参考帧,不会造成解码错误的扩散

【​为什么需要B帧​】

从上面的看,我们知道I和P的解码算法比较简单,资源占用也比较少,I只要自己完成就行了,P呢,也只需要解码器把前一个画面缓存一下,遇到P时就使用之前缓存的画面就好了,如果视频流只有I和P,解码器可以不管后面的数据,边读边解码,线性前进,大家很舒服。那么为什么还要引入B帧?

网络上的电影很多都采用了B帧,因为B帧记录的是前后帧的差别,比P帧能节约更多的空间,但这样一来,文件小了,解码器就麻烦了,因为在解码时,不仅要用之前缓存的画面,还要知道下一个I或者P的画面(也就是说要预读预解码)

而且,B帧不能简单地丢掉,因为B帧其实也包含了画面信息,如果简单丢掉,并用之前的画面简单重复,就会造成画面卡(其实就是丢帧了),并且由于网络上的电影为了节约空间,往往使用相当多的B帧,B帧用的多,对不支持B帧的播放器就造成更大的困扰,画面也就越卡。

image.png

显示和解码顺序示意图(上面是帧的显示顺序,下面是帧的解码顺序)

GoP

在IPB这种编码算法下,如果编码后一帧的数据丢失,则会影响后面的解码,如果强行解码,就会出现花屏等现象(因为部分图像间的差异信息找不到了)。

因而,在实际的编码器上,一般会对图像进行分组,分组后的图像称为​GoP​(Group of Pictures)。

每隔一定数量(比如100帧)的图像,就对一帧完整的图像进行编码。一个序列的第一个图像叫做 IDR 图像(立即刷新图像),IDR 图像都是 I 帧图像。因此整个GoP序列的第1帧也被称为​关键帧​。

这样,即使前面丢了很多数据,只要一个新的关键帧到来,就能继续正确地解码。如果前一个序列出现重大错误,在这里可以获得重新同步的机会。IDR图像之后的图像永远不会使用IDR之前的图像的数据来解码。

GoP可以是固定的,也可以是按需的(比如没有数据丢失就不用生成关键帧,或者丢失比较严重时就多生成几个关键帧)。

image.png

GoP示意图

只解析对应时间的帧

前面介绍过,在MP4中只有关键帧是可以独立解码的,其他帧的解码都依赖于之前解码的帧,所以第一步必须找到关键帧,从关键帧开始解码到目标帧。

因此,实现思路是寻找对应时间所在的GoP序列,再将整个GoP序列解码并转化为ImageBitmap保存

function createFindFrameHelper(videoSamples, config) {
  const { codedWidth: width, codedHeight: height } = config;
  let videoFrames = [];
  let videoDecoder;

  function reset() {
    videoFrames = [];
    videoDecoder = new VideoDecoder({
      output: (videoFrame) => {
        const cvs = new OffscreenCanvas(width, height);
        const ctx = cvs.getContext("2d");
        ctx.drawImage(videoFrame, 0, 0, width, height);
        const bitmap = cvs.transferToImageBitmap();
        videoFrames.push({ ts: videoFrame.timestamp, bitmap: bitmap });
        videoFrame.close();
      },
      error: (err) => {
        console.error("videoDecoder错误:", err);
      },
    });
    videoDecoder.configure(config);
  }

  // 寻找对应关键帧所对应的GoP,并进行解码
  async function decode(time) {
    let hasFrame = false;
    let startIndex = 0;
    let endIndex = videoSamples.length;
    for (let i = 0; i < videoSamples.length; i++) {
      const curFrame = videoSamples[i];
      if (time < curFrame.cts) {
        hasFrame = true;
      }
      // 将关键帧的索引存下来
      if (curFrame.is_sync) {
        if (!hasFrame) {
          startIndex = i;
        }
        if (hasFrame) {
          endIndex = i;
          break;
        }
      }
    }
    const chunks = videoSamples.slice(startIndex, endIndex);

    chunks.forEach((v) => {
      videoDecoder.decode(
        new EncodedVideoChunk({
          type: v.is_sync ? "key" : "delta",
          timestamp: (1e6 * v.cts) / v.timescale,
          duration: (1e6 * v.duration) / v.timescale,
          data: v.data,
        })
      );
    });
    videoDecoder.flush().catch((e) => console.log(e));
  }

  // 等待解码完成
  async function wait() {
    return new Promise((resolve) => {
      if (videoDecoder.decodeQueueSize > 0) {
        const id = setInterval(() => {
          if (videoDecoder.decodeQueueSize === 0) {
            clearInterval(id);
            resolve(null);
          }
        }, 50);
      } else {
        resolve(null);
      }
    });
  }

  reset();

  return async function find(time) {
    // 如果当前有已经解析好的视频帧就尝试寻找
    if (videoFrames.length > 0) {
      // 如果要找的时间对应的帧不在这个数组内,就重新解码
      if (
        time < videoFrames[0].ts ||
        time > videoFrames[videoFrames.length - 1].ts
      ) {
        reset();
        decode(time);
        await wait();
      }
    } else {
      reset();
      decode(time);
      await wait();
    }

    for (let i = 0; i < videoFrames.length; i++) {
      if (time <= videoFrames[i].ts) {
        return videoFrames[i];
      }
    }
  };
}

export default function App() {
  const helper = useRef();
  const [max, setMax] = useState(100);
  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const [videoSamples, mp4File, mp4Info] = await handleArrayBuffer(
            await file.arrayBuffer()
          );
          setMax(videoSamples.length);
          // const { frames } = await genFrame(
          //   videoSamples,
          //   getExtractFileConfig(mp4File, mp4Info)
          // );
          // console.log(frames);

          helper.current = createFindFrameHelper(
            videoSamples,
            getExtractFileConfig(mp4File, mp4Info)
          );
        }}
      />
      <Slider
        defaultValue={0}
        max={max}
        onChange={async (v) => {
          const res = await helper.current(v * 16666.66);
          const canvas = document.querySelector("canvas")!;
          const context = canvas.getContext("2d")!;
          context.clearRect(0, 0, canvas.width, canvas.height);
          if (res?.bitmap) {
            context.drawImage(res.bitmap, 0, 0, canvas.width, canvas.height);
          }
        }}
        style={{ width: 200 }}
      />
      <canvas></canvas>
    </div>
  );
}

image.png

image.png

image.png

解码得到的每帧视频所占用的内存是巨大的,假设一个RGB像素需要24bit,那么一帧1080P的视频将占用将近6MB的内存。

在2-1节介绍stss时,示例视频每隔300帧才有一个关键帧,把300帧图像存到内存中显然是不合适的

为了减少内存中保存帧的数量,我们可以从最近时刻的关键帧开始解码,边解码边close不是我们需要的帧,直到获取到目标时刻的帧

可以参考webav 中的VideoFrameFinder​中的#parseFrame​方法

贴一下旧版的代码,这个易读一些

#parseFrame = async (
  time: number,
  dec: VideoDecoder | null,
  aborter: { abort: boolean },
): Promise<VideoFrame | null> => {
  if (dec == null || dec.state === 'closed' || aborter.abort) return null;

  if (this.#videoFrames.length > 0) {
    const vf = this.#videoFrames[0];
    if (time < vf.timestamp) return null;
    // 弹出第一帧
    this.#videoFrames.shift();
    // 第一帧过期,找下一帧
    if (time > vf.timestamp + (vf.duration ?? 0)) {
      vf.close();
      return this.#parseFrame(time, dec, aborter);
    }
    // 符合期望
    return vf;
  }

  // 缺少帧数据
  if (this.#outputFrameCnt < this.#inputChunkCnt && dec.decodeQueueSize > 0) {
    // 解码中,等待,然后重试
    await sleep(15);
  } else if (this.#videoDecCusorIdx >= this.samples.length) {
    // decode completed
    return null;
  } else {
    // 启动解码任务,然后重试
    let endIdx = this.#videoDecCusorIdx + 1;
    // 该 GoP 时间区间有时间匹配,且未被删除的帧
    let hasValidFrame = false;
    for (; endIdx < this.samples.length; endIdx++) {
      const s = this.samples[endIdx];
      if (!hasValidFrame && !s.deleted && time < s.cts + s.duration) {
        hasValidFrame = true;
      }
      // 找一个 GoP,所以是下一个关键帧结束
      if (s.is_sync) break;
    }
    // 如果该 GoP 区间存在要找的那一帧,就开始解码
    if (hasValidFrame) {
      const samples = this.samples.slice(this.#videoDecCusorIdx, endIdx);
      // 如果第一帧不是关键帧
      if (samples[0]?.is_sync !== true) {
        Log.warn('First sample not key frame');
      } else {
        // 格式化MP4box.js得到的sample,并且创建EncodedVideoChunk对象
        const chunks = await Promise.all(
          samples.map((s) =>
            sample2Chunk(s, EncodedVideoChunk, this.localFileReader),
          ),
        );
        // Wait for the previous asynchronous operation to complete, at which point the task may have already been terminated
        if (aborter.abort) return null;

        this.#lastVfDur = chunks[0]?.duration ?? 0;
        // 开始解码
        for (const c of chunks) dec.decode(c);
        this.#inputChunkCnt += chunks.length;
        // windows 设备 flush 可能不会被 resolved
        dec.flush().catch((err) => {
          // 异常处理
        });
      }
    }
    this.#videoDecCusorIdx = endIdx;
  }
  return this.#parseFrame(time, dec, aborter);
};

2-2 解码音频

认识PCM

采样

音频文件的生成过程是将声音信息采样、量化和编码产生的数字信号的过程,人耳所能听到的声音,最低的频率是从20Hz起一直到最高频率20KHz,因此音频文件格式的最大带宽是20KHz。

image.png

  • 红色曲线​:表示原始信号。
  • 蓝色垂直线段​:表示当前时间点对原始信号的一次采样。采样是一系列基于振幅(amplitude和相同时间间隔的样本。这也是为什么采样过程被称为PAM的原因。
  • PAM​:(Pulse Amplitude Modulation)是一系列离散样本之的结果。

每秒钟的样本数也被称之为采样率(Sample rate)。在Sampling图示案例中,采样率为每秒34次。意味着在一秒的时间内,原始信号被采样了34次(也就是蓝色垂直线段的数量)。

通常,采样率的单位用Hz表示,例如1Hz表示每秒钟对原始信号采样一次,1KHz表示每秒钟采样1000次。

根据奈奎斯特的理论,只有采样频率高于声音信号最高频率的两倍时,才能把数字信号表示的声音还原成为原来的声音,所以音频文件的采样率一般在40~50KHz(CD音质采样率44.1KHz,数字电视、DVD、电影采样率48KHz)。

量化

位深: 用于描述采用多少二进制位表示一个音频模拟信号采样,常见的位深有8bit(只能记录 256 个数)、16bit(可以到 65536 个数, CD 标准)、32bit,其中16bit最常见。显而易见,位深度越大对模拟信号的描述越真实,对声音的描述更加准确。

image.png上图使用8bit位深来描述

编码

编码是将量化后的信号转换为二进制的过程,编码出来的数据就是PCM数据。

对声音进行采样、量化并进行编码过程被称为脉冲编码调制(Pulse Code Modulation),简称PCM。​PCM数据是最原始的音频数据完全无损​,理论上来说采样率大于40kHz的音频格式都可以称之为无损格式。

采样率越高,声音的还原程度越高,质量就越好,同时占用空间会变大。为了压缩PCM,出现了各种压缩格式,无损压缩(ALAC、APE、FLAC)和有损压缩(MP3、AAC、OGG、WMA)

音频帧

音视频文件播放时,为了保证音视频同步,程序需要根据每帧的播放时间戳进行有序播放。但是每个音频采样数据太小了,如果每个采样数据都记录播放时间戳的话,那数据量就大了,因此有了音频帧。

音频帧实际上就是把一小段时间的音频采样数据打包起来

解析MP4中的音频帧

import { Button, Slider } from "@arco-design/web-react";
import MP4Box from "MP4Box";
import { useEffect, useRef, useState } from "react";

async function handleArrayBuffer(buffer) {
  const audioSamples = [];
  const file = MP4Box.createFile();
  const mp4InfoPromise = new Promise((resolve, reject) => {
    file.onReady = resolve;
    file.onError = reject;
  });

  buffer.fileStart = 0;
  file.appendBuffer(buffer);

  const mp4Info = await mp4InfoPromise;
  const aTrack = mp4Info.audioTracks[0];
  const aTrackId = aTrack?.id;
  if (aTrackId != null)
    file.setExtractionOptions(aTrackId, "audio", { nbSamples: 100 });

  return new Promise((resolve) => {
    file.onSamples = (id, type, samples) => {
      audioSamples.push(
        ...samples.map((s) => {
          // 格式化时长
          return {
            ...s,
            cts: (s.cts / s.timescale) * 1e6,
            dts: (s.dts / s.timescale) * 1e6,
            duration: (s.duration / s.timescale) * 1e6,
            timescale: 1e6,
          };
        })
      );
      if (audioSamples.length === aTrack.nb_samples) {
        resolve([audioSamples, mp4Info.audioTracks[0]]);
      }
    };
    file.start();
  });
}

function createFindFrameHelper(audioSamples, config) {
  let audioFrames = [];
  let audioDecoder;

  function reset() {
    audioFrames = [];
    audioDecoder = new AudioDecoder({
      output: (audioFrame) => {
        // 假设format是f32-planar
        const pcm = [];
        for (let i = 0; i < audioFrame.numberOfChannels; i += 1) {
          const size = audioFrame.allocationSize({ planeIndex: i });
          const buffer = new ArrayBuffer(size);
          audioFrame.copyTo(buffer, { planeIndex: i });
          pcm.push(new Float32Array(buffer));
        }
        audioFrames.push({ pcm, ts: audioFrame.timestamp });
        audioFrame.close();
      },
      error: (err) => {
        console.error("AudioDecoder错误:", err);
      },
    });
    audioDecoder.configure(config);
  }

  function decode(time0, time1) {
    let beginIndex = 0;
    let endIndex = 0;
    let findBegin = false;
    for (let i = 0; i < audioSamples.length; i++) {
      if (!findBegin && audioSamples[i].cts >= time0) {
        beginIndex = i;
        findBegin = true;
      }
      if (audioSamples[i].cts >= time1) {
        endIndex = i;
        break;
      }
    }

    const samples = audioSamples.slice(beginIndex, endIndex + 1);

    samples.map((v) =>
      audioDecoder.decode(
        new EncodedAudioChunk({
          type: "key",
          timestamp: v.cts,
          duration: v.duration,
          data: v.data,
        })
      )
    );
  }

  async function wait() {
    return new Promise((resolve) => {
      if (audioDecoder.decodeQueueSize > 0) {
        const id = setInterval(() => {
          if (audioDecoder.decodeQueueSize === 0) {
            clearInterval(id);
            resolve(null);
          }
        }, 5);
      } else {
        resolve(null);
      }
    });
  }
  reset();
  return async function find(time0, time1) {
    audioFrames = [];
    decode(time0, time1);
    await wait();
    return audioFrames;
  };
}

const concatPCM = (audios) => {
  const len = audios.reduce((prev, cur) => prev + cur.pcm[0].length, 0);
  const left = new Float32Array(len);
  const right = new Float32Array(len);
  let index = 0;
  for (let i = 0; i < audios.length; i++) {
    const [l, r] = audios[i].pcm;
    for (let j = 0; j < l.length; j++) {
      left[index] = l[j];
      right[index] = r[j];
      index++;
    }
  }
  return [left, right];
};

function App() {
  const [max, setMax] = useState(1);
  const [, update] = useState({});
  const [isPlaying, setIsPlaying] = useState(false);
  const [sampleRate, setSampleRate] = useState(48000);
  const finder = useRef();
  const playSecond = useRef(0);

  const timeDuration = 0.5 * 1e6;
  useEffect(() => {
    if (!finder.current || !isPlaying) {
      return;
    }
    const ctx = new AudioContext();
    let startAt = 0;

    const intervalId = setInterval(() => {
      playAudio();
    }, timeDuration / 1e3);

    const playAudio = async () => {
      const t = String(playSecond.current);
      console.time(t);
      const audios = await finder.current(
        1e6 * playSecond.current,
        1e6 * playSecond.current + timeDuration
      );
      console.log(audios);

      if (playSecond.current >= max) {
        playSecond.current = 0;
      }
      playSecond.current += timeDuration / 1e6;
      update({});
      const audio = concatPCM(audios);
      const len = audio[0]?.length ?? 0;
      if (len === 0) return;
      const buf = ctx.createBuffer(2, len, sampleRate);
      buf.copyToChannel(audio[0], 0);
      buf.copyToChannel(audio[1], 1);
      const source = ctx.createBufferSource();
      source.buffer = buf;
      source.connect(ctx.destination);
      startAt = Math.max(ctx.currentTime, startAt);
      source.start(startAt);
      startAt += buf.duration;
      console.timeEnd(t);
    };
    return () => {
      clearInterval(intervalId);
    };
  }, [isPlaying]);

  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const [audioSamples, aTrack] = await handleArrayBuffer(
            await file.arrayBuffer()
          );
          setSampleRate(aTrack.audio.sample_rate);
          const lastSample = audioSamples[audioSamples.length - 1];
          setMax(Math.floor(lastSample.cts / lastSample.timescale));
          finder.current = createFindFrameHelper(audioSamples, {
            codec: aTrack.codec,
            numberOfChannels: aTrack.audio.channel_count,
            sampleRate: aTrack.audio.sample_rate,
          });
        }}
      />
      <Slider
        value={playSecond.current}
        min={0}
        max={max}
        onChange={async (v) => {
          playSecond.current = v;
          update({});
        }}
        style={{ width: 200 }}
      />
      <Button onClick={() => setIsPlaying(true)}>播放</Button>
      <Button onClick={() => setIsPlaying(false)}>停止</Button>
    </div>
  );
}

export default App;

和解析视频帧类似,先通过MP4box.js解封装,接着使用AudioDecoder​解码出音频帧

AudioDecoder的output输出的是AudioData格式,还需要对音频帧进行处理才是原始的PCM数据

PCM音频原始数据的存储排列方式中,有planar、packed

双通道的planar,格式为:LLLL...RRRR...(L 左声道 R 右声道)

双通道的packed,格式为:LRLRLRLR...

每个音频的位深不一样(代码中audioFrame.format=f32-planar,f32表示位深是32,音频样本使用32位浮点数表示,planar表示使用LLLL...RRRR...的格式存储),需要把位深转化成统一的,可以参考audiodata2pcm或webav的工具函数extractPCM4AudioData。代码里面默认是f32-planar

得到PCM数据后,就能用Web audio api播放音频了

一个简单而典型的 Web audio 流程如下:

  1. 创建音频上下文
  2. 在音频上下文里创建源 — 例如 <audio>​, 振荡器,流
  3. 创建效果节点,例如混响、双二阶滤波器、平移、压缩
  4. 为音频选择一个目的地,例如你的系统扬声器
  5. 连接源到效果器,对目的地进行效果输出

常见的web audio​音频源有OscillatorNodeAudioBufferSourceNodeMediaElementAudioSourceNodeMediaStreamAudioSourceNode

我们选用AudioBufferSourceNode​来播放解析出来的PCM

首先创建节点ctx.createBufferSource()

接着设置buffer到节点上source.buffer = buf

最后把节点连接到destination(输出节点)source.connect(ctx.destination)​,并source.start()​就能听到声音了

示例代码还存在一些问题,现在是每隔500ms解码一次音频,当我们把时间缩短,33ms解码一次,会发现播放地非常卡,这是因为实时解码也是需要时间的,可以通过预解码提前缓存帧来解决

更好的实现请参考webav中的AudioFrameFinder,为了方便阅读,下面贴两段旧版本的核心代码

上面代码是播放未来x秒的音频,webav是播放两次调用find​之间的时间间隔那一段音频

按照源码的逻辑,第一次调用find方法或者find方法调用间隔超过100ms会获取不到音频数据

find = async (time: number): Promise<Float32Array[]> => {
  // 前后获取音频数据差异不能超过 100ms
  if (this.#dec == null || time <= this.#ts || time - this.#ts > 0.1e6) {
    this.reset();
    this.#ts = time;
    // 找到需要解码的帧的开始索引
    for (let i = 0; i < this.samples.length; i++) {
      if (this.samples[i].cts < time) continue;
      this.#decCusorIdx = i;
      break;
    }
    return [];
  }

  this.#curAborter.abort = true;
  const deltaTime = time - this.#ts;
  this.#ts = time;

  this.#curAborter = { abort: false };
  return await this.#parseFrame(deltaTime, this.#dec, this.#curAborter);
};
#parseFrame = async (
  deltaTime: number,
  dec: ReturnType<typeof createAudioChunksDecoder> | null = null,
  aborter: { abort: boolean }
): Promise<Float32Array[]> => {
  if (dec == null || aborter.abort || dec.state === "closed") return [];

  const emitFrameCnt = Math.ceil(deltaTime * (this.#sampleRate / 1e6));
  if (emitFrameCnt === 0) return [];

  // 数据满足需要
  if (this.#pcmData.frameCnt > emitFrameCnt) {
    return emitAudioFrames(this.#pcmData, emitFrameCnt);
  }

  if (this.#decoding) {
    // 解码中,等待
    await sleep(15);
  } else if (this.#decCusorIdx >= this.samples.length - 1) {
    // decode completed
    return [];
  } else {
    // 启动解码任务
    const samples = [];
    let i = this.#decCusorIdx;
    while (i < this.samples.length) {
      const s = this.samples[i];
      i += 1;
      if (s.deleted) continue;
      samples.push(s);
      if (samples.length >= 10) break;
    }
    this.#decCusorIdx = i;

    this.#decoding = true;
    dec.decode(
      samples.map(
        (s) =>
          new EncodedAudioChunk({
            type: "key",
            timestamp: s.cts,
            duration: s.duration,
            data: s.data!,
          })
      ),
      (pcmArr, done) => {
        if (pcmArr.length === 0) return;
        // 音量调节 ...
        // 补齐双声道 ...
        this.#pcmData.data.push(pcmArr as [Float32Array, Float32Array]);
        this.#pcmData.frameCnt += pcmArr[0].length;
        if (done) this.#decoding = false;
      }
    );
  }
  return this.#parseFrame(deltaTime, dec, aborter);
};

解码MP3中的音频

解码MP3中的音频代码更简单,只需要调用AudioContext中的decodeAudioData就行了,函数会返回整个音频的解码结果

import { Button, Slider } from "@arco-design/web-react";
import { useEffect, useRef, useState } from "react";

const defalutSampleRate = 48000;
async function handleArrayBuffer(arrayBuffer) {
  const ctx = new AudioContext({
    sampleRate: defalutSampleRate,
  });
  console.time("decodeTime");
  const audioData = await ctx.decodeAudioData(arrayBuffer);
  console.timeEnd("decodeTime");
  const pcm = Array(audioData.numberOfChannels)
    .fill(0)
    .map((_, idx) => {
      return audioData.getChannelData(idx);
    });
  return pcm;
}

function createFindFrameHelper(pcm) {
  return (time0, time1) => {
    const start = Math.ceil((time0 / 1e6) * defalutSampleRate);
    const end = Math.ceil((time1 / 1e6) * defalutSampleRate);
    return [pcm[0].slice(start, end), pcm[1].slice(start, end)];
  };
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [max, setMax] = useState(1);
  const [, update] = useState({});
  const playSecond = useRef(0);
  const finder = useRef();
  const timeDuration = (1000 / 30) * 1e3;

  useEffect(() => {
    if (!finder.current || !isPlaying) {
      return;
    }
    const ctx = new AudioContext();
    let startAt = 0;

    const intervalId = setInterval(() => {
      playAudio();
    }, timeDuration / 1e3);

    const playAudio = async () => {
      const t = String(playSecond.current);
      console.time(t);
      const audio = await finder.current(
        1e6 * playSecond.current,
        1e6 * playSecond.current + timeDuration
      );
      console.log(audio);

      if (playSecond.current >= max) {
        playSecond.current = 0;
      }
      playSecond.current += timeDuration / 1e6;
      update({});
      const len = audio[0]?.length ?? 0;
      if (len === 0) return;
      const buf = ctx.createBuffer(2, len, defalutSampleRate);
      buf.copyToChannel(audio[0], 0);
      buf.copyToChannel(audio[1], 1);
      const source = ctx.createBufferSource();
      source.buffer = buf;
      source.connect(ctx.destination);
      startAt = Math.max(ctx.currentTime, startAt);
      source.start(startAt);
      startAt += buf.duration;
      console.timeEnd(t);
    };
    return () => {
      clearInterval(intervalId);
    };
  }, [isPlaying]);

  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const pcm = await handleArrayBuffer(await file.arrayBuffer());
          setMax(pcm[0].length / defalutSampleRate);
          finder.current = createFindFrameHelper(pcm);
        }}
      />
      <Slider
        value={playSecond.current}
        min={0}
        max={max}
        onChange={async (v) => {
          playSecond.current = v;
          update({});
        }}
        style={{ width: 200 }}
      />
      <Button onClick={() => setIsPlaying(true)}>播放</Button>
      <Button onClick={() => setIsPlaying(false)}>停止</Button>
    </div>
  );
}

decodeAudioData​会一次性解码整个音频文件,将解码出来的pcm数据保存起来,播放的时候通过计算当前时间所在帧的位置,然后slice pcm数据丢给BufferSource​就能播放了

由于不需要边解码边播放,可以发现本节音频的播放会比上一节的流畅很多

解码一个4分钟的音频大约需要700多毫秒,我觉得完全可以在用户上传文件的时候顺便把音频给解码存下来,播放的时候直接slice或许会更好

image.png

音频响度计

常用的剪辑软件(下图是达芬奇)都有显示音频声音大小的柱状图,也就是响度计

image.png

计算方式可以参考 blog.csdn.net/lijian2017/…

image.png

实时计算均方根可能会影响性能,因此可以考虑取音频帧中的最大值进行计算,效果也可以

动画.gif

2-3 实现视频缩略图

接下来,我们尝试实现视频缩略图模块

image.png

剪映PC端视频缩略图模块

在剪映PC端中将缩放程度调到最大,也就是缩放单位为1帧

image.png

image.png可以发现,在一帧的间隔内,存在多个重复缩略图(图2-3-2 第二张图更明显)

我们大胆地猜测剪映PC端是这么实现的:解析视频的时候隔X帧抽一帧,然后填充满视频轨道,空余的部分用相邻帧填充

当我们愉快地使用canvas​将图片画出来,缩放轨道进行测试,你会发现canvas​显示不出来了,它承载不了如此多的图片

image.png

我们改变策略:给canvas​设置最大宽度,通过marginLeft​使canvas​一直处于可视范围内

image.png

canvas.parentElement!.style.marginLeft = `${marginLeft}px`;

const context = canvas.getContext('2d') as CanvasRenderingContext2D;
// 缩略图图片的宽度
const imgWidth = (canvasHeight * video.width) / video.height;
// 整个轨道能显示多少张图片=轨道的宽度/缩略图图片的宽度
const imgCount = Math.ceil(video.trackWidth / imgWidth);
// 每一张缩略图占多少帧
const step = video.frameCount / imgCount;

for (let i = 0; i < imgCount; i++) {
  //根据比例:当前图片所在的帧数/整个视频总帧数=当前缩略图的下标/缩略图数组的长度
  const curImgIndex = Math.floor(
    ((i * step) / video.frameCount) * screenshots.length
  );
  let offsetX = i * imgWidth;
  // 如果有marginLeft,需要减去marginLeft的长度
  if (marginLeft) {
    if (offsetX + imgWidth < marginLeft) {
      continue;
    }
    offsetX -= marginLeft;
  }
  const bit = screenshots[curImgIndex];
  context.drawImage(bit, offsetX, 0, imgWidth, canvasHeight);
}

到此为止,我们已经成功实现了视频缩略图模块

image.png

2-4 实现视频动画

接着,我们来实现视频的动画

image.png

剪映的动画类似CSS动画中的@keyframes关键帧动画

@keyframes slidein {
  from {
    transform: translateX(0%);
  }

  to {
    transform: translateX(100%);
  }
}

实现视频动画的关键在于,如何实现类似于@keyframes的关键帧动画

很容易想到如下数据结构来表示一个动画的内容

{
  // 动画标识
  key: 'fadeIn',
  // 动画名称
  name: '渐显',
  // 动画类型,进入还是退出
  type: 'enter',
  // 关键帧
  frames: [
    {
      // 动画的进度,范围是从0到1
      process: 0,
      // 其他的各种熟悉
      opcity: 0
    },
    {
      process: 1,
      opcity: 1
    }
  ]
}

按照这个数据结构,我们就能用js实现CSS关键帧

导入一个补间动画库tween.js,tween.js是一个补间动画的引擎,传入时间,他会帮你计算,返回在当前时间下,元素属性应该处于什么状态

const group = new TWEEN.Group();
const frames = animate.frames;
const TIME = animate.duration * 1000;
let prev = frames[0];
const durationFrame = TIME / 30;

for (let i = 0; i < frames.length - 1; i++) {
  // 当前关键帧的动画进度
  const curProcess = frames[i + 1].process - frames[i].process;
  const from = { ...prev, ...frames[i] };
  const to = { ...from, ...frames[i + 1] };
  prev = to;

  // 为每个关键帧配置单独delay,在将各个区间组合起来,就是一个完整的keyframes动画了
  new TWEEN.Tween(from, group)
    .to(to, curProcess * TIME)
    .delay(frames[i].process * TIME)
    .onUpdate(obj => {
        // 更新视频的属性(opacity、translate)
    })
    .start(0);
}

在运行的时候调用group.update(time)​Tween会执行回调函数onUpdate​,就能更新视频的属性了

image.png

image.png

image.png

2-5 实现视频转场

转场是两段视频之间的过渡

image.png

ffmpeg​可以使用filter_complex​滤镜来实现转场。filter_complex​非常强大,可以裁剪视频片段、音量调整、音频混音、创建各种视频特效,并且可以集成gl-transitions实现视频转场,并且web也能使用这个库

image.png

根据官网的介绍:GLSL 是一种功能强大且易于学习的语言,非常适合图像效果。这是实现转场的终极语言

WebGL的本质是通过JavaScript操作一些OpenGL接口,而OpenGL需要使用GLSL语言,因此要搞定视频转场,需要了解一下WebGL的基本知识

官网都说glsl这语言易于学习了,相信掘金的网友花个一两个小时就学会了

本章内容需要了解WebGl,可以参考《WebGL编程指南》这本书


接下来我们尝试实现常见的圆形转场动画.gif

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
</head>

<body>
  <canvas width="500" height="500"></canvas>
  <script>

    function initShader(vertexShaderCode, fragmentShaderCode) {
      const vertexShader = gl.createShader(gl.VERTEX_SHADER);
      gl.shaderSource(vertexShader, vertexShaderCode);
      gl.compileShader(vertexShader);

      const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
      gl.shaderSource(fragmentShader, fragmentShaderCode);
      gl.compileShader(fragmentShader);

      const program = gl.createProgram();
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      gl.linkProgram(program);
      gl.useProgram(program);
      gl.program = program
      return program;
    }

    function initVerticesTexCoords() {
      const verticesTexCoords = new Float32Array([
        // 左上点。左边两个是顶点,右边两个是纹理
        -1, 1, 0.0, 1.0,
        // 左下
        -1, -1, 0.0, 0.0,
        // 右上
        1, 1, 1.0, 1.0,
        // 右下
        1, -1, 1.0, 0.0,
      ]);
      const FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;

      const verticesTexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, verticesTexBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);

      const a_Position = gl.getAttribLocation(gl.program, "a_Position");
      gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
      gl.enableVertexAttribArray(a_Position);

      const a_TexCoord = gl.getAttribLocation(gl.program, "a_TexCoord");
      gl.vertexAttribPointer(
        a_TexCoord,
        2,
        gl.FLOAT,
        false,
        FSIZE * 4,
        FSIZE * 2
      );
      gl.enableVertexAttribArray(a_TexCoord);
    }

    function createImage(url) {
      return new Promise((resolve) => {
        const img = new Image();
        img.src = url;
        img.crossOrigin = "anonymous";
        img.onload = () => {
          resolve(img);
        };
      });
    }

    function loadTexTure(img, index) {
      const texture = gl.createTexture();
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
      gl.activeTexture(gl[`TEXTURE${index}`]);
      gl.bindTexture(gl.TEXTURE_2D, texture);

      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
      const u_Sampler = gl.getUniformLocation(
        gl.program,
        `u_Sampler${index}`
      );
      gl.uniform1i(u_Sampler, index);
    }

    function floor(value) {
      return Math.pow(2, Math.floor(Math.log(value) / Math.LN2));
    }

    function createValidImageCanvas(img) {
      const canvas = new OffscreenCanvas(floor(img.width), floor(img.height))
      const context = canvas.getContext('2d');
      context.drawImage(img, 0, 0, canvas.width, canvas.height);
      return canvas
    }

    const vertexShaderSrc = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
  gl_Position = a_Position;
  v_TexCoord = a_TexCoord;
}
`;

    const fragmentShaderSrc = `
precision highp float;
varying vec2 v_TexCoord;

uniform sampler2D u_Sampler0;
uniform sampler2D u_Sampler1;

uniform float u_process;
uniform vec2 u_resolution;

float Circle(vec2 p,float r){
  return length(p)-r;
}

vec4 transition(vec2 uv){
  float ratio=u_resolution.x/u_resolution.y;
  vec2 p=uv;
  p-=.5;
  p.x*=ratio;
  float d=length(p)-u_process;
  float c=smoothstep(0.,.01,d);
  return mix(texture2D(u_Sampler0, v_TexCoord),texture2D(u_Sampler1, v_TexCoord),1.-c);
}

void main() {
  vec2 uv=gl_FragCoord.xy/u_resolution;
  gl_FragColor = transition(uv);
}
`;

    /** @type {HTMLCanvasElement} */
    const canvas = document.querySelector("canvas");
    const gl = canvas.getContext("webgl");

    const program = initShader(vertexShaderSrc, fragmentShaderSrc)
    initVerticesTexCoords()

    const u_resolution = gl.getUniformLocation(program, "u_resolution");
    gl.uniform2f(u_resolution, canvas.width, canvas.height);

    (async () => {
      const img0 = await createImage(
        "./a.png"
      );
      loadTexTure(createValidImageCanvas(img0), 0);

      const img1 = await createImage(
        "./b.png"
      );
      loadTexTure(createValidImageCanvas(img1), 1);

      gl.clearColor(0, 0, 0, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);

      let i = 0;
      setInterval(() => {
        const u_process = gl.getUniformLocation(program, "u_process");
        gl.uniform1f(u_process, i);
        i += 0.02;
        由f (i > 1) i = 0;
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }, 33);
    })();
  </script>
</body>

</html>

动画.gif

通过以上代码我们就能实现类似剪映的转场了

js代码的流程

初始化 --> 画一个正方形 --> 贴图

initShader() --> initVerticesTexCoords() --> loadTexTure(createValidImageCanvas(img0), 0)

  1. initShader​函数功能是:创建顶点渲染器、创建片元渲染器、创建并链接程序对象
  2. initVerticesTexCoords​函数功能是:配置顶点坐标、纹理坐标verticesTexCoords​,通过vertexAttribPointer​函数告诉显卡按照什么格式(从什么地方开始、偏移多少单位)读取顶点数据
  3. loadTexTure​函数功能是:加载贴图,​webgl图片的尺寸需要是 2 的幂次方​,因此还需要createValidImageCanvas​格式化图片的宽高。为何上传到显卡的纹理尺寸最好是2的次幂?

glsl代码中transition的执行流程

u_resolution​是通过js传递的画布尺寸,u_process​是通过js传递的动画进度(范围是0-1)

// uv是把canvas的每个点映射到[0,1]的区间,画布上的每个点都会执行一遍这个函数
vec4 transition(vec2 uv){
  // 画布的宽高比
  float ratio=u_resolution.x/u_resolution.y;
  vec2 p=uv;
  // 将圆形中心移动到画布中心,坐标轴原点在左下角
  p-=.5;
  // uv坐标的值不会自动地适应画布的比例,当画布不是正方形时,会展示一个椭圆
  // 需要将将坐标拉伸回来,也就是乘画布比例
  p.x*=ratio;
  // length函数是计算uv上点到原点的距离
  // d=0表示在圆上,d<0表示在圆内,d>0表示在圆外
  float d=length(p)-u_process;
  // 消除圆的边缘锯齿,如果d<0返回0,d>0.01返回1,d在0-0.01间返回0-1的逐渐变化的值
  // 第二个值越大边缘就越模糊
  float c=smoothstep(0.,.01,d);
  // mix是一个混合函数,第3个参数代表了混合程度,等于0返回第一个参数,等于1返回第二个参数,在0-1的中间值就返回第一个参数和第二个参数的之间逐渐变化的值
  return mix(texture2D(u_Sampler0, v_TexCoord),texture2D(u_Sampler1, v_TexCoord),1.-c);
}

当u_process=0.5时的效果

image.png

当uv=(0.0,0.0),d<0,表示在圆内,c=0,因此这个点显示第二张图片的像素

当uv=(0.353,0.353),d≈0,表示在圆上附近,假设d=0.005,d∈[0,0.01],因此c∈[0,1],因此这个点显示两张图片之间的过渡像素

当uv=(0.5,0.5),d>0,表示在圆外,c=1,因此这个点显示第一张图片的像素

到此,我们就实现了两张图片之间的转场,视频转场就是不断替换代码里的图片

真正开发的时候,我们可以使用gl-transitions这个库,里面提供了67种转场

image.png

gl-transitions​只是提供了类似于上文中transition​这种核心转场函数

还需要引入gl-transition​帮助我们拼接WebGl的字符串,引入gl-texture2d​帮助我们渲染图片纹理

image.png

具体用法参考www.npmjs.com/package/gl-…

集成之后,我们就能实现视频转场效果了,下图是选用gl-transitions.com/editor/circ… 作为视频的转场动画.gif

2-6 合成视频

github.com/gpac/mp4box…

import { Button } from "@arco-design/web-react";
import MP4Box from "MP4Box";
import { useState } from "react";

const WIDTH = 1920;
const HEIGHT = 1080;
const TIMESCALE = 1e6;
const SAMPLE_RATE = 48000;
const frameDuration = 33 * 1e3;

let pcm;

function createMP4Encoder() {
  const mp4file = MP4Box.createFile();
  const videoEncodingTrackOptions = {
    timescale: TIMESCALE,
    width: WIDTH,
    height: HEIGHT,
    brands: ["isom", "iso2", "avc1", "mp41"],
    avcDecoderConfigRecord: null,
  };
  const audioEncodingTrackOptions = {
    timescale: TIMESCALE,
    samplerate: SAMPLE_RATE,
    channel_count: 2,
    hdlr: "soun",
    type: "mp4a",
  };
  let vTrackId;
  let aTrackId;
  const videoEncoder = new VideoEncoder({
    error: (e) => console.log(e),
    output: (chunk, meta) => {
      if (vTrackId === undefined) {
        videoEncodingTrackOptions.avcDecoderConfigRecord =
          meta.decoderConfig?.description;
        vTrackId = mp4file.addTrack(videoEncodingTrackOptions);
      }
      const buffer = new ArrayBuffer(chunk.byteLength);
      chunk.copyTo(buffer);

      const dts = chunk.timestamp;
      mp4file.addSample(vTrackId, buffer, {
        duration: chunk.duration ?? 0,
        dts,
        cts: dts,
        is_sync: chunk.type === "key",
      });
    },
  });
  videoEncoder.configure({
    width: WIDTH,
    height: HEIGHT,
    codec: "avc1.42E032",
    framerate: 30,
    bitrate: 5e6,
    hardwareAcceleration: "prefer-hardware",
    avc: { format: "avc" },
  });
  const audioEncoder = new AudioEncoder({
    error: (e) => console.log(e),
    output: (chunk, meta) => {
      if (aTrackId === undefined) {
        aTrackId = mp4file.addTrack({
          ...audioEncodingTrackOptions,
        });
      }
      const buffer = new ArrayBuffer(chunk.byteLength);
      chunk.copyTo(buffer);
      const dts = chunk.timestamp;
      mp4file.addSample(aTrackId, buffer, {
        duration: chunk.duration,
        dts,
        cts: dts,
        is_sync: chunk.type === "key",
      });
    },
  });
  audioEncoder.configure({
    codec: "mp4a.40.2",
    sampleRate: SAMPLE_RATE,
    numberOfChannels: 2,
    bitrate: 128000,
  });

  return {
    mp4file,
    flush() {
      return Promise.all([videoEncoder.flush(), audioEncoder.flush()]);
    },
    close() {
      videoEncoder.close();
      audioEncoder.close();
    },
    encodeVideo(videoFrame, config) {
      videoEncoder.encode(videoFrame, config);
      videoFrame.close();
    },
    encodeAudio(audioData) {
      audioEncoder.encode(audioData);
      audioData.close();
    },
  };
}

async function exportVideo() {
  const encoder = createMP4Encoder();
  const duration = 10 * 1e6;
  let process = 0;
  const run = async () => {
    let ts = 0;
    let frameIndex = 0;
    while (true) {
      if (ts > duration) {
        await encoder.flush();
        return;
      }
      process = ts / duration;

      const canvas = renderVideo(frameIndex);
      try {
        const videoFrame = new VideoFrame(canvas, {
          duration: frameDuration,
          timestamp: ts,
        });

        encoder.encodeVideo(videoFrame, {
          keyFrame: frameIndex % 150 === 0,
        });
      } catch (e) {
        console.log(e);
      }
      const pcm = renderAudio(frameIndex);
      try {
        const audioData = new AudioData({
          timestamp: ts,
          numberOfChannels: 2,
          numberOfFrames: pcm.length / 2,
          sampleRate: SAMPLE_RATE,
          format: "f32-planar",
          data: pcm,
        });

        encoder.encodeAudio(audioData);
      } catch (e) {
        console.log(e);
      }

      frameIndex += 1;
      ts += frameDuration;
      console.log(process);
    }
  };
  await run();

  const buffer = await encoder.mp4file.getBuffer();
  encoder.close();
  downloadBuffer(buffer);
  return window.URL.createObjectURL(new Blob([buffer]), {
    type: "video/mp4",
  });
}

function downloadBuffer(buffer) {
  const blobUrl = window.URL.createObjectURL(new Blob([buffer]));
  const aEl = document.createElement("a");
  document.body.appendChild(aEl);
  aEl.setAttribute("href", blobUrl);
  aEl.setAttribute("download", `${Date.now()}.mp4`);
  aEl.setAttribute("target", "_self");
  aEl.click();
  document.body.removeChild(aEl);
}

function renderVideo(frameIndex) {
  const canvas = new OffscreenCanvas(1920, 1080);
  const ctx = canvas.getContext("2d")!;
  ctx.fillStyle = "#fff";
  ctx.font = "40px sans-serif";
  ctx.fillText(`current frame:${frameIndex}`, 800, 800);
  ctx.fillStyle = "#007ae5";
  ctx.fillRect(frameIndex * 2, frameIndex * 2, 100, 100);
  return canvas;
}

function renderAudio(frameIndex) {
  const start = Math.ceil(
    ((frameIndex * frameDuration) / TIMESCALE) * SAMPLE_RATE
  );
  const end = start + Math.floor((SAMPLE_RATE * frameDuration) / 1e6);
  const a = pcm[0].slice(start, end);
  const b = pcm[1].slice(start, end);
  const arr = new Float32Array(a.length * 2);
  for (let i = 0; i < a.length; i++) {
    arr[i] = a[i];
    arr[i + a.length] = b[i + a.length];
  }

  return arr;
}

export default function App() {
  const [disabled, setDisabled] = useState(true);
  const [videoUrl, setVideoUrl] = useState("");
  return (
    <div>
      <input
        type="file"
        onChange={async (event) => {
          const file = event.target.files[0];
          const ctx = new AudioContext({
            sampleRate: SAMPLE_RATE,
          });
          const audioData = await ctx.decodeAudioData(await file.arrayBuffer());
          pcm = Array(audioData.numberOfChannels)
            .fill(0)
            .map((_, idx) => {
              return audioData.getChannelData(idx);
            });
          setDisabled(false);
        }}
      />
      <Button
        type="primary"
        onClick={async () => {
          setVideoUrl(await exportVideo());
        }}
        disabled={disabled}
      >
        导出
      </Button>
      <video src={videoUrl} width={1000} controls></video>
    </div>
  );
}

动画.gif

上面代码的作用是:上传一个音频,将音频和canvas合成一个10s的视频

合成视频和解码视频的步骤相反

首先创建音频轨道和视频轨道mp4file.addTrack

把canvas塞到videoFrame中再丢给videoEncoder编码

把pcm塞到audioData中再丢给audioEncoder编码

接着再通过mp4file.addSample​把编码过后的数据塞进mp4file里面

需要注意的是,AudioData需要的音频格式是[LLLRRR],需要将左右声道合成一个数组

认识codec

VideoEncoder​的codec​为avc1.42E032​,AudioEncoder​的codec​为mp4a.40.2​这些是什么意思

相关文档

datatracker.ietf.org/doc/html/rf…

developer.mozilla.org/en-US/docs/…

zh.wikipedia.org/wiki/H.264/…

wiki.whatwg.org/wiki/Video_…

avc1代表的编码采样数据,也就是视频采用 H.264 编码

AVC编码由三个部分组成:​avc1.PPCCLL​,后面6个字符是16进制

  • PP=profile_idc 表示 H.264 编解码中编码器的特性概述
  • CC=constraint_set flags 表示编码级别的约束条件
  • LL=level_idc ​表示视频编码本身的视频参数,比如分辨率,码率,帧率​,转为10进制再除以10就是下图中的level,可以对照表来查询该编码支持的视频参数

AVC有3种规格,从低到高分别为:​Baseline(avc1.42E0xx)、Main(avc1.4D40xx)、High(avc1.6400xx) ​。

  • Baseline(最低Profile)级别支持I/P 帧,只支持无交错(Progressive)和CAVLC,一般用于低阶或需要额外容错的应用,比如视频通话、手机视频等;
  • Main(主要Profile)级别提供I/P/B 帧,支持无交错(Progressive)和交错(Interlaced),同样提供对于CAVLC 和CABAC 的支持,用于主流消费类电子产品规格如低解码(相对而言)的mp4、便携的视频播放器、PSP和Ipod等;
  • High(高端Profile,也叫FRExt)级别在Main的基础上增加了8x8 内部预测、自定义量化、无损视频编码和更多的YUV 格式(如4:4:4),用于广播及视频碟片存储(蓝光影片),高清电视的应用。

image.png

宏块

宏块,英文Macroblock,是视频编码技术中的一个基本概念。通过将画面分成一个个大小不同的块来在不同位置实行不同的压缩策略。

在视频编码中,一个编码图像通常划分成若干宏块组成,一个宏块由一个亮度像素块和附加的两个色度像素块组成。一般来说,亮度块为16x16大小的像素块,而两个色度图像像素块的大小依据其图像的采样格式而定,如:对于YUV420采样图像,色度块为8x8大小的像素块。每个图象中,若干宏块被排列成片的形式,视频编码算法以宏块为单位,逐个宏块进行编码,组织成连续的视频码流。

以1920×1080视频举例,由于H.264默认是使用16X16大小的区域作为一个宏块,可知

水平宏块数=Math.ceil(1920/16)=120

垂直宏块数=Math.ceil(1080/16)=68

**每帧宏块数=120×68=8160**

查上表可知,最小的大于8160的值是8192,对应level为4,从上图最后一列可验证结果是正确的

level=4对应的每秒最大宏块数是245760,对应帧率为245760/8160=30.1176,也就是最高支持30帧/s

如果需要60帧/s,则level应该选取4.2


mp4a.40.2​代表此视频的音频部分采用 MPEG-4 压缩编码

40是一个16进制的数,对应的是Audio ISO/IEC 14496-3, 由MP4注册机构分配

2是一个10进制的数,代表一种编码规范

image.png

2-7 其他功能

视频轨道拖拽、裁剪

由于时间轴放到最大他的刻度是帧,为了运算方便,我把帧作为时间的基本单位

由于一个项目可能会有多个相同的视频资源,因此把这些用户上传的这些资源单独存起来,每个轨道只是引用资源的materialId

image.png

image.png

编辑轨道里面的每一个素材,我们通过start​约束外边距(从第几帧开始播放),通过offsetLeft​约束内边距(资源左边被裁剪了多少帧)

type ITrackType = "video" | "audio" | "img" | "text" | "effect";

// 每一个轨道的数据结构
interface ITrack {
  type: ITrackType;
  trackId: string;
  list: {
    trackItemId: string;
    // 视频、音频、图片等资源的唯一id
    materialId: string;
    type: ITrackType;
    // 资源在整个项目中从第几帧开始
    start: number;
    // 资源在整个项目中在第几帧结束
    end: number;
    // 资源左边被裁剪了多少帧
    offsetLeft: number;
    // 资源右边被裁剪了多少帧
    offsetRight: number;
    ......
  }[];
  operation: {
    isSilent: boolean;
  };
}

每一次拖拽素材、裁剪素材,我们可以通过鼠标移动的距离、当前缩放程度下多少像素为一帧,来计算出真实的坐标

调整音频音量

声音的本质是波,声波的振幅表示音量大小,给pcm乘一个数就能改变音量大小了,也能通过gainNode来调整大小

gainNode = AudioCtx.createGain();
gainNode.gain.value = volume.value / 100;

滤镜/特效

滤镜的本质就是颜色的变换,而处理每一个像素点,用webgl再合适不过了,核心在于转换的算法

例如:Photoshop CS图像黑白调整功能的计算公式为:gray = (max - mid) * ratio_max + (mid - min) * ratio_max_mid + min

公式中:gray为像素灰度值,max、mid和min分别为图像像素R、G、B分量颜色的最大值、中间值和最小值,ratio_max为max所代表的分量颜色(单色)比率,ratio_max_mid则为max与mid两种分量颜色所形成的复色比率。


特效也是属于图像处理的范畴,可以参考glfx.js:evanw.github.io/glfx.js/dem…

image.png

3 参考资料

riju.github.io/web-codecs/…

developer.chrome.google.cn/docs/web-pl…

《深入理解FFmpeg》

《WebGL编程指南》

web.developers.google.cn/articles/we…

developer.mozilla.org/en-US/docs/…

blog.csdn.net/maozefa/art…

剪映的bug

在刻度线外神奇的游标

这些图标太小太不清晰了,特别是那个静音图标

image.png

视频缩略图没有完全显示整个视频画面,导致我的缩略图被裁掉了image.png

image.png

留下你的脚步
推荐阅读