Kapwing: Leistungsstarke Videobearbeitung für das Web

Dank leistungsstarker APIs wie IndexedDB und WebCodecs sowie Leistungstools können Creator jetzt mit Kapwing hochwertige Videoinhalte im Web bearbeiten.

Joshua Grossberg
Joshua Grossberg

Der Onlinevideokonsum ist seit Beginn der Pandemie rasant gestiegen. Nutzer verbringen immer mehr Zeit damit, sich endlose Videos in hoher Qualität auf Plattformen wie TikTok, Instagram und YouTube anzusehen. Kreative und Kleinunternehmer auf der ganzen Welt benötigen schnelle und nutzerfreundliche Tools, um Videoinhalte zu erstellen.

Unternehmen wie Kapwing ermöglichen es, diese Videoinhalte direkt im Web zu erstellen, indem sie die neuesten leistungsstarken APIs und Leistungstools verwenden.

Über Kapwing

Kapwing ist ein webbasierter kollaborativer Video-Editor, der hauptsächlich für Gelegenheits-Creator wie Game-Streamer, Musiker, YouTube-Creator und Meme-Maker entwickelt wurde. Außerdem ist es eine gute Anlaufstelle für Unternehmer, die auf einfache Weise eigene Inhalte für soziale Medien erstellen möchten, z. B. Facebook- und Instagram-Anzeigen.

Nutzer finden Kapwing, wenn sie nach einer bestimmten Aufgabe suchen, z. B. „Wie kann ich ein Video zuschneiden?“, „Wie kann ich meinem Video Musik hinzufügen?“ oder „Wie kann ich die Größe eines Videos ändern?“ Sie können mit nur einem Klick das tun, wonach sie gesucht haben – ohne den zusätzlichen Aufwand, einen App-Shop aufzurufen und eine App herunterzuladen. Im Web können Nutzer ganz einfach nach der Aufgabe suchen, bei der sie Hilfe benötigen, und sie dann erledigen.

Nach diesem ersten Klick können Kapwing-Nutzer noch viel mehr tun. Sie können kostenlose Vorlagen verwenden, neue Ebenen mit kostenlosen Stockvideos hinzufügen, Untertitel einfügen, Videos transkribieren und Hintergrundmusik hochladen.

So bringt Kapwing Echtzeitbearbeitung und Zusammenarbeit ins Web

Das Internet bietet zwar einzigartige Vorteile, stellt aber auch besondere Herausforderungen. Kapwing muss eine flüssige und präzise Wiedergabe komplexer, mehrschichtiger Projekte auf einer Vielzahl von Geräten und Netzwerkbedingungen ermöglichen. Dazu verwenden wir eine Vielzahl von Web-APIs, um unsere Leistungs- und Funktionsziele zu erreichen.

IndexedDB

Für eine leistungsstarke Bearbeitung müssen alle Inhalte unserer Nutzer lokal auf dem Client gespeichert werden, um das Netzwerk nach Möglichkeit zu umgehen. Im Gegensatz zu einem Streamingdienst, bei dem Nutzer in der Regel nur einmal auf einen Inhalt zugreifen, verwenden unsere Kunden ihre Assets häufig, Tage und sogar Monate nach dem Upload.

Mit IndexedDB können wir unseren Nutzern einen nichtflüchtigen Speicher bereitstellen, der einem Dateisystem ähnelt. So werden über 90% der Medienanfragen in der App lokal ausgeführt. Die Einbindung von IndexedDB in unser System war sehr einfach.

Hier ist ein Beispiel für einen Standard-Initialisierungscode, der beim Laden der App ausgeführt wird:

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');
      },
    }
  );

Wir übergeben eine Version und definieren eine upgrade-Funktion. Dieser Wert wird für die Initialisierung oder zum Aktualisieren des Schemas verwendet, falls erforderlich. Wir übergeben Rückruffunktionen zur Fehlerbehandlung, blocked und blocking, die sich als nützlich erwiesen haben, um Probleme für Nutzer mit instabilen Systemen zu vermeiden.

Beachten Sie abschließend unsere Definition eines Primärschlüssels keyPath. In unserem Fall ist dies eine eindeutige ID, die wir mediaLibraryID nennen. Wenn ein Nutzer unserem System ein Medium hinzufügt, sei es über unseren Uploader oder eine Drittanbietererweiterung, fügen wir das Medium mit dem folgenden Code unserer Medienbibliothek hinzu:

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 ist eine intern definierte Funktion, die den IndexedDB-Zugriff serialisiert. Dies ist für alle Read-Modify-Write-Vorgänge erforderlich, da die IndexedDB API asynchron ist.

Sehen wir uns nun an, wie wir auf Dateien zugreifen. Unten sehen Sie unsere getAsset-Funktion:

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;
}

Wir haben unsere eigene Datenstruktur, idbCache, mit der IndexedDB-Zugriffe minimiert werden. IndexedDB ist zwar schnell, aber der Zugriff auf den lokalen Speicher ist noch schneller. Wir empfehlen diesen Ansatz, sofern Sie die Größe des Caches verwalten.

Das subscribers-Array, das dazu dient, den gleichzeitigen Zugriff auf IndexedDB zu verhindern, wäre sonst beim Laden üblich.

Web Audio API

Die Audiovisualisierung ist für die Videobearbeitung unglaublich wichtig. Sehen Sie sich dazu einen Screenshot aus dem Editor an:

Der Editor von Kapwing bietet ein Menü für Medien, darunter mehrere Vorlagen und benutzerdefinierte Elemente, darunter einige Vorlagen, die für bestimmte Plattformen wie LinkedIn spezifisch sind, eine Zeitleiste, die Video, Audio und Animation voneinander trennt, einen Canvas-Editor mit Optionen für die Exportqualität, eine Videovorschau und weitere Funktionen.

Das ist ein YouTube-Video, das in unserer App häufig verwendet wird. Die Person bewegt sich im Clip nicht viel, sodass die visuellen Miniaturansichten der Zeitleiste nicht so nützlich sind, um zwischen den Abschnitten zu wechseln. Die Wellenform des Audiosignals hingegen zeigt Spitzen und Täler, wobei die Täler in der Regel den Aussetzern in der Aufnahme entsprechen. Wenn du in die Zeitachse hineinzoomst, werden detailliertere Audioinformationen angezeigt, wobei Täler auf Ruckler und Pausen hinweisen.

Unsere Nutzerstudien haben gezeigt, dass Creator sich beim Zusammenschneiden ihrer Inhalte oft an diesen Wellenformen orientieren. Mit der Web Audio API können wir diese Informationen leistungsstark präsentieren und schnell aktualisieren, wenn die Zeitachse herangezoomt oder gepannt wird.

Das folgende Snippet zeigt, wie das geht:

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;
    } 
  );

Wir übergeben diesem Helfer das Asset, das in IndexedDB gespeichert ist. Nach Abschluss aktualisieren wir das Asset in IndexedDB und in unserem eigenen Cache.

Wir erfassen Daten zur audioBuffer mit dem AudioContext-Konstruktor. Da wir jedoch nicht auf die Gerätehardware rendern, verwenden wir die OfflineAudioContext, um auf eine ArrayBuffer zu rendern, in der wir Amplitudendaten speichern.

Die API selbst gibt Daten mit einer wesentlich höheren Abtastrate zurück, als für eine effektive Visualisierung erforderlich ist. Deshalb haben wir die Daten manuell auf 200 Hz herunterskaliert, was für nützliche, visuell ansprechende Wellenformen ausreicht.

WebCodecs

Bei bestimmten Videos sind die Track-Thumbnails für die Zeitleistennavigation nützlicher als die Wellenformen. Das Erstellen von Thumbnails ist jedoch ressourcenintensiver als das Erstellen von Wellenformen.

Wir können nicht jedes potenzielle Thumbnail beim Laden im Cache speichern. Daher ist eine schnelle Dekodierung bei der Zeitachse und beim Schwenken/Zoomen entscheidend für eine leistungsstarke und responsive Anwendung. Das Nadelöhr für eine flüssige Frame-Darstellung ist die Decodierung von Frames. Bis vor Kurzem haben wir dafür einen HTML5-Videoplayer verwendet. Die Leistung dieses Ansatzes war nicht zuverlässig und die App reagierte beim Frame-Rendering häufig verzögert.

Vor Kurzem sind wir zu WebCodecs übergegangen, die in Webworkern verwendet werden können. Dadurch sollten wir in der Lage sein, Thumbnails für eine große Anzahl von Ebenen zu zeichnen, ohne die Leistung des Hauptthreads zu beeinträchtigen. Die Implementierung von Webworkern ist noch in Arbeit. Unten findest du eine Übersicht über unsere aktuelle Implementierung des Hauptthreads.

Eine Videodatei enthält mehrere Streams: Video, Audio, Untertitel usw., die zusammen „gemischt“ werden. Damit WebCodecs verwendet werden können, benötigen wir zuerst einen demultiplexten Videostream. Wir demuxen MP4s mit der mp4box-Bibliothek, wie hier gezeigt:

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);
  }
};

Dieses Snippet bezieht sich auf eine demuxer-Klasse, mit der wir die Schnittstelle zu MP4Box kapseln. Wir greifen noch einmal über IndexedDB auf das Asset zu. Diese Segmente werden nicht unbedingt in Byte-Reihenfolge gespeichert und die Methode appendBuffer gibt den Offset des nächsten Chunks zurück.

So decodieren wir einen Videoframe:

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 };
};

Die Struktur des Demuxers ist ziemlich komplex und geht über den Rahmen dieses Artikels hinaus. Dabei wird jeder Frame in einem Array mit dem Namen samples gespeichert. Mit dem Demuxer wird der dem gewünschten Zeitstempel am nächsten liegende vorherige Keyframe ermittelt. Dort muss die Videodekodierung beginnen.

Videos bestehen aus vollständigen Frames, sogenannten Schlüssel- oder I-Frames, sowie viel kleineren Delta-Frames, die oft als P- oder B-Frames bezeichnet werden. Die Dekodierung muss immer bei einem Keyframe beginnen.

Die Anwendung decodiert Frames so:

  1. Decoder mit einem Frame-Output-Callback instanziieren
  2. Konfigurieren des Dekoders für den jeweiligen Codec und die Eingabeauflösung.
  3. Erstellen einer encodedVideoChunk mit Daten aus dem Demultiplexer.
  4. Durch Aufruf der Methode decodeEncodedFrame.

Wir wiederholen diesen Vorgang, bis wir den Frame mit dem gewünschten Zeitstempel erreichen.

Nächste Schritte

Wir definieren Skalierung auf unserer Frontend-Seite als die Fähigkeit, eine präzise und leistungsstarke Wiedergabe beizubehalten, wenn Projekte größer und komplexer werden. Eine Möglichkeit, die Leistung zu steigern, besteht darin, so wenige Videos wie möglich gleichzeitig bereitzustellen. Dadurch riskieren wir jedoch langsame und ruckelige Übergänge. Wir haben zwar interne Systeme entwickelt, um Videokomponenten zur Wiederverwendung im Cache zu speichern, aber die Möglichkeiten zur Steuerung von HTML5-Video-Tags sind begrenzt.

In Zukunft werden wir möglicherweise versuchen, alle Medien mit WebCodecs abzuspielen. So können wir sehr genau festlegen, welche Daten wir zwischenspeichern, was die Leistung verbessern sollte.

Außerdem können wir große Touchpad-Berechnungen besser an Webworker auslagern und Dateien intelligenter vorab abrufen und Frames vorab generieren. Wir sehen große Chancen, die allgemeine Anwendungsleistung zu optimieren und die Funktionalität mit Tools wie WebGL zu erweitern.

Wir möchten unsere Investitionen in TensorFlow.js fortsetzen, das wir derzeit für die intelligente Entfernung von Hintergründen verwenden. Wir planen, TensorFlow.js auch für andere anspruchsvolle Aufgaben wie Objekterkennung, Merkmalsextraktion und Stiltransfer zu nutzen.

Wir freuen uns darauf, unser Produkt mit nativer Leistung und Funktionalität im freien und offenen Web weiterzuentwickeln.