Kapwing: Leistungsstarke Videobearbeitung für das Web

Creator können jetzt mit Kapwing hochwertige Videoinhalte im Web bearbeiten – dank leistungsstarken APIs wie IndexedDB und WebCodecs und Performance-Tools.

Joshua Grossberg
Joshua Grossberg

Seit Beginn der Pandemie ist die Onlinenutzung von Videos rasant gestiegen. Nutzer verbringen immer mehr Zeit damit, Videos auf Plattformen wie TikTok, Instagram und YouTube anzusehen. Kreative und Kleinunternehmer auf der ganzen Welt brauchen schnelle und nutzerfreundliche Tools, um Videoinhalte zu erstellen.

Unternehmen wie Kapwing ermöglichen die Erstellung all dieser Videoinhalte direkt im Web, indem sie die neuesten leistungsstarken APIs und Performance-Tools nutzen.

Über Kapwing

Kapwing ist ein webbasierter Video-Editor, der sich vor allem für Gelegenheits-Creatives wie Game-Streamer, Musiker, YouTube-Creator und Meme-Fans eignet. Es ist auch eine erste Anlaufstelle für Geschäftsinhaber, die eine einfache Möglichkeit benötigen, ihre eigenen sozialen Inhalte wie Facebook- und Instagram-Anzeigen zu erstellen.

Nutzer entdecken Kapwing, indem sie nach einer bestimmten Aufgabe suchen, z. B. „Video zuschneiden“, „Musik zu meinem Video hinzufügen“ oder „Größe eines Videos anpassen“. Sie können mit nur einem Klick das tun, wonach sie gesucht haben, ohne den App-Shop öffnen oder eine App herunterladen zu müssen. Das Web macht es Nutzern leicht, genau nach einer Aufgabe zu suchen, bei der sie Hilfe benötigen, und diese dann zu erledigen.

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

Kapwing bringt Echtzeit-Bearbeitung und -Zusammenarbeit im Web

Das Web bietet zwar einzigartige Vorteile, ist aber auch mit einigen Herausforderungen verbunden. Kapwing muss komplexe, mehrschichtige Projekte auf einer Vielzahl von Geräten und Netzwerkbedingungen reibungslos und präzise wiedergeben. 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 der Nutzer im Client vorhanden sein und das Netzwerk muss nach Möglichkeit vermieden werden. Im Gegensatz zu einem Streamingdienst, bei dem Nutzer in der Regel einmal auf einen Inhalt zugreifen, verwenden unsere Kunden ihre Assets häufig, Tage und sogar Monate nach dem Upload.

IndexedDB ermöglicht es uns, unseren Nutzern einen nichtflüchtigen dateisystemähnlichen Speicher bereitzustellen. Das Ergebnis ist, dass über 90% der Medienanfragen in der Anwendung lokal ausgeführt werden. Die Integration von IndexedDB in unser System war sehr einfach.

Hier ist ein 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 wird zur Initialisierung oder zur Aktualisierung des Schemas bei Bedarf verwendet. Wir übergeben Callbacks zur Fehlerbehandlung, blocked und blocking. Diese Callbacks haben sich als nützlich erwiesen, um Probleme für Nutzer mit instabilen Systemen zu vermeiden.

Beachten Sie zum Schluss unsere Definition des Primärschlüssels keyPath. In unserem Fall ist dies eine eindeutige ID, die mediaLibraryID aufgerufen wird. Wenn ein Nutzer ein Medienelement zu unserem System hinzufügt, sei es über unseren Uploader oder die Erweiterung eines Drittanbieters, fügen wir die Medien mit dem folgenden Code zu unserer Mediathek 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 unsere eigene intern definierte Funktion, die den Zugriff auf IndexedDB partitioniert. Dies ist für alle Vorgänge vom Typ „Lesen-Ändern-Schreiben“ erforderlich, da die IndexedDB API asynchron ist.

Sehen wir uns nun an, wie wir auf Dateien zugreifen. Hier ist 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 die Zugriffe auf IndexedDBs minimiert werden. Während IndexedDB schnell ist, ist der Zugriff auf den lokalen Speicher schneller. Wir empfehlen diesen Ansatz, solange Sie die Größe des Cache verwalten.

Das Array subscribers, das verwendet wird, um den gleichzeitigen Zugriff auf IndexedDB zu verhindern, wäre beim Laden ansonsten üblich.

Web-Audio-API

Audiovisualisierungen sind unglaublich wichtig für die Videobearbeitung. Wenn Sie den Grund dafür verstehen möchten, sehen Sie sich einen Screenshot des Editors an:

Der Editor von Kapwing verfügt über ein Medienmenü mit mehreren 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 Vorschau des Videos und weitere Funktionen.

Dies ist ein Video im YouTube-Stil, das in unserer App üblich ist. Der Nutzer bewegt sich während des Clips nicht sehr viel, sodass die Miniaturansichten der Zeitachsen für die Navigation zwischen den Abschnitten nicht so nützlich sind. Andererseits zeigt die Audiowellenform Höhen und Tiefen, wobei die Täler in der Regel der Totzeit in der Aufzeichnung entsprechen. Wenn Sie die Zeitachse vergrößern, sehen Sie detailliertere Audioinformationen mit Werten, die Rucklern und Pausen entsprechen.

Unsere Nutzerstudien zeigen, dass Creator sich beim Erstellen von Inhalten oft von diesen Wellenformen leiten lassen. Mit der Web Audio API können wir diese Informationen leistungsfähig präsentieren und beim Zoomen oder Schwenken der Zeitachse schnell aktualisieren.

Das folgende Snippet veranschaulicht, wie das funktioniert:

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 des Vorgangs aktualisieren wir das Asset in IndexedDB und unserem eigenen Cache.

Daten zum audioBuffer werden mit dem AudioContext-Konstruktor erfasst. Da jedoch nicht auf der Gerätehardware gerendert wird, verwenden wir OfflineAudioContext, um in ein ArrayBuffer zu rendern, in dem Amplitudendaten gespeichert werden.

Die API selbst gibt Daten mit einer Abtastrate zurück, die viel höher ist als für eine effektive Visualisierung erforderlich. Aus diesem Grund haben wir manuell auf 200 Hz heruntersampelt, was unserer Erfahrung nach für nützliche und optisch ansprechende Wellenformen ausreicht.

WebCodecs

Bei bestimmten Videos sind die Track-Thumbnails für die Navigation auf der Zeitachse nützlicher als die Wellenformen. Das Generieren von Miniaturansichten ist jedoch ressourcenintensiver als das Generieren von Wellenformen.

Da wir nicht jede potenzielle Miniaturansicht beim Laden im Cache speichern können, ist eine schnelle Decodierung beim Schwenken/Zoomen der Zeitachse für eine leistungsstarke und reaktionsschnelle Anwendung von entscheidender Bedeutung. Der Engpass beim Zeichnen von Frames ist das Decodieren von Frames. Bis vor Kurzem nutzten wir einen HTML5-Videoplayer. Die Leistung dieses Ansatzes war nicht zuverlässig und die Reaktionszeit der App beim Frame-Rendering wurde häufig vermindert.

Kürzlich sind wir nun auf WebCodecs übergegangen, der in Web-Workern verwendet werden kann. Dies sollte unsere Fähigkeit verbessern, Miniaturansichten für große Mengen von Ebenen zu erstellen, ohne die Leistung des Hauptthreads zu beeinträchtigen. Während die Web-Worker-Implementierung noch in Bearbeitung ist, geben wir unten einen Überblick über unsere bestehende Hauptthread-Implementierung.

Eine Videodatei enthält mehrere Streams: Video, Audio, Untertitel usw., die „gemuxt“ sind. Um WebCodecs verwenden zu können, benötigen wir zunächst einen aufgeteilten Videostream. Wir deduxen 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 verweist auf die Klasse demuxer, mit der die Schnittstelle zu MP4Box gekapselt wird. Wir greifen wieder ü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 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 Demuxer ist ziemlich komplex und würde den Rahmen dieses Artikels sprengen. Jeder Frame wird in einem Array mit dem Namen samples gespeichert. Wir verwenden den Demuxer, um den Keyframe zu finden, der unserem gewünschten Zeitstempel am nächsten kommt, bei dem wir mit der Videodecodierung beginnen müssen.

Videos bestehen aus vollständigen Frames, die als Key-Frames oder iFrames bezeichnet werden, sowie aus viel kleineren Delta-Frames, die oft als p- oder b-Frames bezeichnet werden. Das Decodieren muss immer mit einem Keyframe beginnen.

Die Anwendung decodiert Frames nach:

  1. Instanziieren des Decoders mit einem Frame-Ausgabe-Callback.
  2. Konfiguriert den Decoder für den jeweiligen Codec und die Eingabeauflösung.
  3. Ein encodedVideoChunk mit Daten aus dem Demuxer erstellen.
  4. Durch Aufruf der Methode decodeEncodedFrame.

Dies geschieht, bis wir den Frame mit dem gewünschten Zeitstempel erreicht haben.

Nächste Schritte

Wir definieren Skalierung in unserem Frontend als die Fähigkeit, eine präzise und leistungsstarke Wiedergabe auch dann aufrechtzuerhalten, wenn Projekte größer und komplexer werden. Eine Möglichkeit, die Leistung zu skalieren, besteht darin, so wenige Videos wie möglich auf einmal bereitzustellen. Dabei besteht jedoch die Gefahr, dass die Übergänge langsam und ruckelfrei sind. Wir haben interne Systeme entwickelt, um Videokomponenten zur Wiederverwendung im Cache zu speichern. Es gibt jedoch Einschränkungen bei der Steuerung von HTML5-Video-Tags.

In Zukunft werden wir möglicherweise versuchen, alle Medien mithilfe von WebCodecs wiederzugeben. Auf diese Weise können wir sehr präzise angeben, welche Daten gepuffert werden sollen, und so die Leistung skalieren.

Wir können auch große Touchpad-Berechnungen besser an Web-Worker auslagern und dabei besser mit dem Vorabruf von Dateien und dem Vorgenerieren von Frames umgehen. Wir sehen große Möglichkeiten, die Gesamtleistung der Anwendung zu optimieren und die Funktionalität mit Tools wie WebGL zu erweitern.

Wir möchten weiterhin in TensorFlow.js investieren, das wir derzeit zur intelligenten Entfernung von Hintergründen nutzen. Wir planen, TensorFlow.js für andere anspruchsvolle Aufgaben wie Objekterkennung, Featureextraktion, Stilübertragung usw. zu nutzen.

Wir freuen uns darauf, unser Produkt mit nativ ähnlicher Leistung und Funktionalität in einem kostenlosen und offenen Web weiter zu entwickeln.