Kapwing:强大的 Web 视频编辑工具

借助强大的 API(例如 IndexedDB 和 WebCodecs)和效果工具,创作者现在可以使用 Kapwing 在 Web 上编辑高品质视频内容。

Joshua Grossberg
Joshua Grossberg

自疫情开始以来,在线视频消费量快速增长。人们正花更多时间在 TikTok、Instagram 和 YouTube 等平台上观看无尽的高品质视频。世界各地的广告素材和小企业主都需要快捷易用的工具来制作视频内容。

借助强大的最新 API 和效果工具,Kapwing 等公司让您能够直接在网络上制作所有这些视频内容。

Kapwing 简介

Kapwing 是一款基于网络的协作视频编辑器,主要面向游戏直播者、音乐人、YouTube 创作者和表情包创作者等休闲创作者。对于需要轻松制作自己的社交内容(例如 Facebook 和 Instagram 广告)的商家所有者,此工具也是首选资源。

用户通过搜索特定任务(例如“如何剪辑视频”“为视频添加音乐”或“调整视频大小”)来发现 Kapwing。用户只需点击一下,即可执行所需的搜索,而无需前往应用商店和下载应用。在网络中,用户可以准确地搜索他们需要获得帮助的任务,然后执行相关操作。

首次点击后,Kapwing 用户可以执行更多操作。他们可以探索免费模板、添加新的免费视频素材库视频层、插入字幕、转写视频以及上传背景音乐。

Kapwing 如何将实时编辑和协作功能引入网络

虽然网站具有独特的优势,但也存在独特的挑战。Kapwing 需要在各种设备和网络条件下,流畅且精准地播放复杂的多层项目。为此,我们使用各种 Web API 来实现性能和功能目标。

IndexedDB

为了实现高性能编辑,我们要求所有用户的内容都存储在客户端上,尽可能避免使用网络。与在线播放服务不同(用户通常只会访问一次内容),我们的客户会经常重复使用其上传的资产,有时甚至会在几天甚至几个月后重复使用。

借助 IndexedDB,我们可以为用户提供类似于永久性文件系统的存储空间。结果是,应用中超过 90% 的媒体请求在本地执行。将 IndexedDB 集成到我们的系统非常简单。

下面是一些在应用加载时运行的样板初始化代码:

import {DBSchema, openDB, deleteDB, IDBPDatabase} from 'idb';

let openIdb: Promise <IDBPDatabase<Schema>>;

const db =
  (await openDB) <
  Schema >
  (
    'kapwing',
    version, {
      upgrade(db, oldVersion) {
        if (oldVersion >= 1) {
          // assets store schema changed, need to recreate
          db.deleteObjectStore('assets');
        }

        db.createObjectStore('assets', {
          keyPath: 'mediaLibraryID'
        });
      },
      async blocked() {
        await deleteDB('kapwing');
      },
      async blocking() {
        await deleteDB('kapwing');
      },
    }
  );

我们传递一个版本并定义一个 upgrade 函数。它用于初始化或更新架构(如有必要)。我们会传递错误处理回调 blockedblocking,我们发现这些回调有助于防止系统不稳定的用户遇到问题。

最后,请注意我们对主键 keyPath 的定义。在本例中,这是一个唯一 ID,我们将其称为 mediaLibraryID。当用户通过我们的上传工具或第三方扩展程序向我们的系统添加媒体内容时,我们会使用以下代码将媒体内容添加到媒体库中:

export async function addAsset(mediaLibraryID: string, file: File) {
  return runWithAssetMutex(mediaLibraryID, async () => {
    const assetAlreadyInStore = await (await openIdb).get(
      'assets',
      mediaLibraryID
    );    
    if (assetAlreadyInStore) return;
        
    const idbVideo: IdbVideo = {
      file,
      mediaLibraryID,
    };

    await (await openIdb).add('assets', idbVideo);
  });
}

runWithAssetMutex 是我们自己的内部定义的函数,用于将 IndexedDB 访问序列化。由于 IndexedDB API 是异步的,因此任何读取-修改-写入类型的操作都需要执行此操作。

现在,我们来看看如何访问文件。以下是我们的 getAsset 函数:

export async function getAsset(
  mediaLibraryID: string,
  source: LayerSource | null | undefined,
  location: string
): Promise<IdbAsset | undefined> {
  let asset: IdbAsset | undefined;
  const { idbCache } = window;
  const assetInCache = idbCache[mediaLibraryID];

  if (assetInCache && assetInCache.status === 'complete') {
    asset = assetInCache.asset;
  } else if (assetInCache && assetInCache.status === 'pending') {
    asset = await new Promise((res) => {
      assetInCache.subscribers.push(res);
    }); 
  } else {
    idbCache[mediaLibraryID] = { subscribers: [], status: 'pending' };
    asset = (await openIdb).get('assets', mediaLibraryID);

    idbCache[mediaLibraryID].asset = asset;
    idbCache[mediaLibraryID].subscribers.forEach((res: any) => {
      res(asset);
    });

    delete (idbCache[mediaLibraryID] as any).subscribers;

    if (asset) {
      idbCache[mediaLibraryID].status = 'complete';
    } else {
      idbCache[mediaLibraryID].status = 'failed';
    }
  } 
  return asset;
}

我们有自己的数据结构 idbCache,用于最大限度地减少对 IndexedDB 的访问。虽然 IndexedDB 的速度很快,但访问本地内存的速度更快。只要您能管理缓存的大小,我们就建议您采用这种方法。

subscribers 数组用于防止同时访问 IndexedDB,否则在加载时会很常见。

网络音频 API

音频可视化对于视频编辑非常重要。如需了解原因,请查看编辑器的屏幕截图:

Kapwing 的编辑器有一个媒体菜单,其中包含多个模板和自定义元素(包括一些特定于 LinkedIn 等特定平台的模板);一个用于分隔视频、音频和动画的时间轴;一个带有导出质量选项的画布编辑器;视频预览;以及更多功能。

这是 YouTube 风格的视频,在我们的应用中很常见。用户在整个剪辑中不会进行太多移动,因此时间轴可视缩略图对于切换各个部分而言没什么用。另一方面,音频波形会显示峰值和谷值,谷值通常对应于录音中的空白时间。如果您放大时间轴,则会看到更精细的音频信息,其中谷底对应于卡顿和暂停。

我们的用户研究表明,创作者在剪辑内容时,往往会参考这些波形。借助 Web Audio API,我们可以高效地呈现这些信息,并在时间轴缩放或平移时快速更新。

以下代码段演示了如何执行此操作:

const getDownsampledBuffer = (idbAsset: IdbAsset) =>
  decodeMutex.runExclusive(
    async (): Promise<Float32Array> => {
      const arrayBuffer = await idbAsset.file.arrayBuffer();
      const audioContext = new AudioContext();
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

      const offline = new OfflineAudioContext(
        audioBuffer.numberOfChannels,
        audioBuffer.duration * MIN_BROWSER_SUPPORTED_SAMPLE_RATE,
        MIN_BROWSER_SUPPORTED_SAMPLE_RATE
      );

      const downsampleSource = offline.createBufferSource();
      downsampleSource.buffer = audioBuffer;
      downsampleSource.start(0);
      downsampleSource.connect(offline.destination);

      const downsampledBuffer22K = await offline.startRendering();

      const downsampledBuffer22KData = downsampledBuffer22K.getChannelData(0);

      const downsampledBuffer = new Float32Array(
        Math.floor(
          downsampledBuffer22KData.length / POST_BROWSER_SAMPLE_INTERVAL
        )
      );

      for (
        let i = 0, j = 0;
        i < downsampledBuffer22KData.length;
        i += POST_BROWSER_SAMPLE_INTERVAL, j += 1
      ) {
        let sum = 0;
        for (let k = 0; k < POST_BROWSER_SAMPLE_INTERVAL; k += 1) {
          sum += Math.abs(downsampledBuffer22KData[i + k]);
        }
        const avg = sum / POST_BROWSER_SAMPLE_INTERVAL;
        downsampledBuffer[j] = avg;
      }

      return downsampledBuffer;
    } 
  );

我们将存储在 IndexedDB 中的资源传递给此帮助程序。完成后,我们将在 IndexedDB 以及我们自己的缓存中更新该资产。

我们使用 AudioContext 构造函数收集有关 audioBuffer 的数据,但由于我们不会渲染到设备硬件,因此我们使用 OfflineAudioContext 渲染到用于存储振幅数据的 ArrayBuffer

该 API 本身返回的数据采样率远高于实现有效可视化所需的采样率。因此,我们手动将采样率下采样到 200 Hz,我们发现这个采样率足以生成有用且视觉上令人愉悦的波形。

WebCodecs

对于某些视频,轨道缩略图对于时间轴导航比波形更有用。但是,与生成波形相比,生成缩略图会占用更多资源。

我们无法在加载时缓存每个可能的缩略图,因此在时间轴上平移/缩放时快速解码对实现高性能且响应迅速的应用至关重要。实现流畅帧绘制的瓶颈是解码帧,直到最近,我们一直使用 HTML5 视频播放器来解码帧。这种方法的性能不可靠,我们经常发现在帧渲染期间应用响应速度会降低。

我们最近改用的是 WebCodecs,它可在 Web Worker 中使用。这应该有助于我们在不影响主线程性能的情况下,为大量图层绘制缩略图。虽然 Web Worker 的实现仍在进行,但我们将在下面简要介绍现有的主线程实现。

视频文件包含多个流(视频、音频、字幕等),这些流通过多路复用技术组合在一起。如需使用 WebCodecs,我们首先需要一个多路复用视频流。我们使用 mp4box 库对 mp4 进行多路分配,如下所示:

async function create(demuxer: any) {
  demuxer.file = (await MP4Box).createFile();
  demuxer.file.onReady = (info: any) => {
    demuxer.info = info;
    demuxer._info_resolver(info);
  };
  demuxer.loadMetadata();
}

const loadMetadata = async () => {
  let offset = 0;
  const asset = await getAsset(this.mediaLibraryId, null, this.url);
  const maxFetchOffset = asset?.file.size || 0;

  const end = offset + FETCH_SIZE;
  const response = await fetch(this.url, {
    headers: { range: `bytes=${offset}-${end}` },
  });
  const reader = response.body.getReader();

  let done, value;
  while (!done) {
    ({ done, value } = await reader.read());
    if (done) {
      this.file.flush();
      break;
    }

    const buf: ArrayBufferLike & { fileStart?: number } = value.buffer;
    buf.fileStart = offset;
    offset = this.file.appendBuffer(buf);
  }
};

此代码段引用了 demuxer 类,我们将使用该类封装 MP4Box 接口。我们再次从 IndexedDB 访问资源。这些分段不一定按字节顺序存储,并且 appendBuffer 方法会返回下一个分块的偏移量。

下面是我们解码视频帧的方式:

const getFrameFromVideoDecoder = async (demuxer: any): Promise<any> => {
  let desiredSampleIndex = demuxer.getFrameIndexForTimestamp(this.frameTime);
  let timestampToMatch: number;
  let decodedSample: VideoFrame | null = null;

  const outputCallback = (frame: VideoFrame) => {
    if (frame.timestamp === timestampToMatch) decodedSample = frame;
    else frame.close();
  };  

  const decoder = new VideoDecoder({
    output: outputCallback,
  }); 
  const {
    codec,
    codecWidth,
    codecHeight,
    description,
  } = demuxer.getDecoderConfigurationInfo();
  decoder.configure({ codec, codecWidth, codecHeight, description }); 

  /* begin demuxer interface */
  const preceedingKeyFrameIndex = demuxer.getPreceedingKeyFrameIndex(
    desiredSampleIndex
  );  
  const trak_id = demuxer.trak_id
  const trak = demuxer.moov.traks.find((trak: any) => trak.tkhd.track_id === trak_id);
  const data = await demuxer.getFrameDataRange(
    preceedingKeyFrameIndex,
    desiredSampleIndex
  );  
  /* end demuxer interface */

  for (let i = preceedingKeyFrameIndex; i <= desiredSampleIndex; i += 1) {
    const sample = trak.samples[i];
    const sampleData = data.readNBytes(
      sample.offset,
      sample.size
    );  

    const sampleType = sample.is_sync ? 'key' : 'delta';
    const encodedFrame = new EncodedVideoChunk({
      sampleType,
      timestamp: sample.cts,
      duration: sample.duration,
      samapleData,
    }); 

    if (i === desiredSampleIndex)
      timestampToMatch = encodedFrame.timestamp;
    decoder.decodeEncodedFrame(encodedFrame, i); 
  }
  await decoder.flush();

  return { type: 'value', value: decodedSample };
};

多路分配器的结构非常复杂,不在本文的讨论范围之内。它将每个帧存储在名为 samples 的数组中。我们使用解复mux 来查找与所需时间戳最接近的前关键帧,我们必须从该帧开始进行视频解码。

视频由全帧(称为键或 iframe)以及更小的增量帧(通常称为 p 或 b 帧)组成。解码必须始终从关键帧开始。

应用通过以下方式对帧进行解码:

  1. 使用帧输出回调对解码器进行实例化。
  2. 为特定编解码器和输入分辨率配置解码器。
  3. 使用多路分配器中的数据创建 encodedVideoChunk
  4. 调用 decodeEncodedFrame 方法。

我们会重复此操作,直到找到具有所需时间戳的帧。

后续操作

我们将前端可伸缩性定义为:随着项目变得越来越大、越来越复杂,能够保持精准高效的播放。提高性能的一种方法是一次挂载尽可能少的视频,但这样做可能会导致转场缓慢且不流畅。尽管我们已经开发了用于缓存视频组件以便重复使用的内部系统,但 HTML5 视频代码所提供的控制力仍存在局限性。

将来,我们可能会尝试使用 WebCodecs 播放所有媒体内容。这样一来,我们就可以非常精确地确定要缓冲哪些数据,这应该有助于提升性能。

我们还可以更好地将大量触控板计算工作分流到 web worker,并更智能地预提取文件和预生成帧。我们发现,借助 WebGL 等工具,可以大大优化应用的整体性能并扩展功能。

我们希望继续投资 TensorFlow.js,我们目前使用它来智能移除背景。我们计划利用 TensorFlow.js 来处理其他复杂任务,例如对象检测、特征提取、风格转换等。

最终,我们很高兴能够继续在自由开放的 Web 上构建具有原生应用般的性能和功能的产品。