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

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

Joshua Grossberg
Joshua Grossberg

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

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

Kapwing 简介

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

用户通过搜索特定任务(例如“如何剪辑视频”“为视频添加音乐”或“调整视频大小”)来发现 Kapwing。他们只需点击一下,即可执行所搜索的操作,而无需额外费心前往应用商店下载应用。借助网络,用户可以轻松搜索他们需要帮助完成的确切任务,然后执行相应任务。

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

Kapwing 如何在 Web 上实现实时编辑和协作

虽然网站具有独特的优势,但也存在独特的挑战。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,否则在加载时会很常见。

Web Audio 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,我们首先需要获得解复 mux 的视频串流。我们使用 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 };
};

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

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

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

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

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

后续操作

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

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

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

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

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