Kapwing: Leistungsstarke Videobearbeitung für das Web

Creator können jetzt dank leistungsstarken APIs (wie IndexedDB und WebCodecs) und Leistungstools 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 Editor für gemeinsame Videos, der hauptsächlich für Casual-Creatives wie Game-Streamer, Musiker, YouTube-Creator und Meme-Rs 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 einmal auf einen Inhalt zugreifen, verwenden unsere Kunden diese häufig, Tage oder sogar Monate nach dem Upload wieder.

IndexedDB ermöglicht uns, unseren Nutzern nichtflüchtigen Speicher wie einem Dateisystem zur Verfügung zu stellen. 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 Lese-, Änderungs- und Schreibvorgänge erforderlich, da die IndexedDB API asynchron ist.

Sehen wir uns nun an, wie wir auf Dateien zugreifen. Hier 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 Array subscribers, das verwendet wird, um den gleichzeitigen Zugriff auf IndexedDB zu verhindern, wäre andernfalls beim Laden üblich.

Web Audio API

Audiovisualisierung ist bei der Videobearbeitung unglaublich wichtig. Sehen Sie sich dazu den folgenden Screenshot aus dem Editor an:

Der Editor von Kapwing hat ein Medienmenü mit verschiedenen Vorlagen und benutzerdefinierten Elementen, darunter einige Vorlagen für bestimmte Plattformen wie LinkedIn, eine Zeitachse, die Video, Audio und Animation voneinander trennt, einen Canvas-Editor mit Optionen für die Exportqualität, eine Videovorschau und weitere Funktionen.

Es handelt sich hierbei um ein Video im YouTube-Stil, wie es in unserer App üblich ist. Der Nutzer bewegt sich nicht sehr viel zwischen den Abschnitten, daher sind die visuellen Thumbnails der Zeitachsen weniger hilfreich. 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 Hilfsprogramm das in IndexedDB gespeicherte Asset. 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 Generieren von Miniaturansichten ist jedoch ressourcenintensiver als das Generieren von Wellenformen.

Wir können nicht jede potenzielle Miniaturansicht beim Laden im Cache speichern. Daher ist eine schnelle Decodierung beim Schwenken und Zoomen der Zeitachse für eine leistungsfähige und reaktionsschnelle Anwendung von entscheidender Bedeutung. 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 beim Frame-Rendering gab es oft eine verschlechterte Reaktionsfähigkeit der App.

Kürzlich sind wir auf WebCodecs umgestiegen, die in Web Workern verwendet werden können. Dadurch sollten wir besser in der Lage sein, Miniaturansichten für große Mengen 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 MP4-Dateien 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 Bytereihenfolge gespeichert und die Methode appendBuffer gibt den Offset des nächsten Blocks 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 sehr komplex und liegt außerhalb des Rahmens dieses Artikels. 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 Vollbildern, die als Keyframes oder I-Frames bezeichnet werden, sowie aus viel kleineren Deltaframes, die oft als P- oder B-Frames bezeichnet werden. Die Dekodierung muss immer bei einem Keyframe beginnen.

Die Anwendung decodiert Frames so:

  1. Instanziierung des Decoders mit einem Frame-Ausgabe-Callback
  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 Skalierbarkeit 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 gesamte 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 für andere anspruchsvolle Aufgaben wie Objekterkennung, Featureextraktion, Stilübertragungen usw. zu nutzen.

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