Kapwing : un puissant outil de montage vidéo pour le Web

Les créateurs peuvent désormais modifier des contenus vidéo de haute qualité sur le Web avec Kapwing, grâce à des API puissantes (telles que IndexedDB et WebCodecs) et des outils d'optimisation des performances.

Joshua Grossberg
Joshua Grossberg

La consommation de vidéos en ligne a augmenté rapidement depuis le début de la pandémie. Sur des plates-formes telles que TikTok, Instagram et YouTube, les utilisateurs passent plus de temps qu'à regarder des vidéos de haute qualité. Partout dans le monde, les créatifs et les dirigeants de petites entreprises ont besoin d'outils rapides et faciles à utiliser pour créer du contenu vidéo.

Des entreprises comme Kapwing permettent de créer tout ce contenu vidéo directement sur le Web, en utilisant les dernières API performantes et outils de performances.

À propos de Kapwing

Kapwing est un outil de montage vidéo collaboratif basé sur le Web, conçu principalement pour les créations occasionnelles, telles que les streamers de jeux, les musiciens, les créateurs YouTube et les mèmes. Il s'agit également d'une ressource de référence pour les propriétaires d'entreprise qui recherchent un moyen simple de produire leur propre contenu sur les réseaux sociaux, comme des annonces Facebook et Instagram.

Les internautes découvrent Kapwing en recherchant une tâche spécifique, par exemple "comment couper une vidéo", "ajouter de la musique à ma vidéo" ou "redimensionner une vidéo". Ils peuvent effectuer ce qu'ils recherchent en un seul clic, sans avoir à se rendre sur une plate-forme de téléchargement d'applications ni à télécharger une application. Sur le Web, il est facile pour les utilisateurs de rechercher précisément la tâche pour laquelle ils ont besoin d'aide, puis de le faire.

Après ce premier clic, les utilisateurs de Kapwing peuvent effectuer bien d'autres actions. Ils peuvent découvrir des modèles sans frais, ajouter de nouveaux calques de vidéos sans frais, insérer des sous-titres, transcrire des vidéos et importer de la musique de fond.

Comment Kapwing intègre la collaboration et l'édition en temps réel sur le Web

Bien que le Web offre des avantages uniques, il présente également des défis distincts. Kapwing doit offrir une lecture fluide et précise de projets complexes multicouches sur un large éventail d'appareils et de conditions de réseau. Nous utilisons diverses API Web pour atteindre nos objectifs de performances et de fonctionnalités.

IndexedDB

L'édition hautes performances nécessite que tout le contenu de nos utilisateurs soit disponible sur le client, en évitant le réseau autant que possible. Contrairement à un service de streaming, où les utilisateurs accèdent généralement à un contenu une seule fois, nos clients réutilisent leurs éléments fréquemment, des jours, voire des mois après la mise en ligne.

IndexedDB nous permet de fournir à nos utilisateurs un espace de stockage persistant semblable à un système de fichiers. Résultat : plus de 90% des requêtes multimédias dans l'application sont traitées localement. L'intégration d'IndexedDB dans notre système a été très simple.

Voici du code d'initialisation standard qui s'exécute au chargement de l'application:

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

Nous transmettons une version et définissons une fonction upgrade. Elle est utilisée pour l'initialisation ou pour mettre à jour le schéma si nécessaire. Nous transmettons des rappels de gestion des exceptions, blocked et blocking, qui nous ont semblé utiles pour éviter les problèmes pour les utilisateurs de systèmes instables.

Enfin, notez notre définition d'une clé primaire keyPath. Dans notre cas, il s'agit d'un identifiant unique que nous appelons mediaLibraryID. Lorsqu'un utilisateur ajoute un élément multimédia à notre système, que ce soit via notre outil de mise en ligne ou une extension tierce, nous l'ajoutons à notre bibliothèque multimédia avec le code suivant:

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 est notre propre fonction définie en interne qui sérialise l'accès à IndexedDB. Cela est nécessaire pour toutes les opérations de type lecture-modification-écriture, car l'API IndexedDB est asynchrone.

Voyons maintenant comment nous accédons aux fichiers. Voici notre fonction 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;
}

Nous disposons de notre propre structure de données, idbCache, qui permet de réduire les accès aux bases de données indexées. Bien qu'IndexedDB soit rapide, l'accès à la mémoire locale est plus rapide. Nous vous recommandons cette approche si vous gérez la taille du cache.

Le tableau subscribers, utilisé pour empêcher l'accès simultané à IndexedDB, serait fréquent lors du chargement.

API Web Audio

La visualisation audio est extrêmement importante pour le montage vidéo. Pour le comprendre, consultez une capture d'écran de l'éditeur:

L&#39;éditeur de Kapwing propose un menu multimédia comprenant plusieurs modèles et éléments personnalisés, y compris des modèles spécifiques à certaines plateformes comme LinkedIn ; une timeline qui sépare la vidéo, l&#39;audio et l&#39;animation ; un éditeur de canevas avec des options de qualité d&#39;exportation ; un aperçu de la vidéo et plus de fonctionnalités.

Il s'agit d'une vidéo de style YouTube, courante dans notre application. L'utilisateur ne bouge pas beaucoup dans le clip, donc les miniatures visuelles de la timeline ne sont pas aussi utiles pour naviguer entre les sections. En revanche, la forme d'onde audio montre des pics et des creux, les creux correspondant généralement au temps mort dans l'enregistrement. Un zoom avant sur la timeline permet d'afficher des informations audio plus précises, avec des creux correspondant aux saccades et aux pauses.

D'après nos recherches sur l'expérience utilisateur, les créateurs sont souvent guidés par ces formes d'ondes lorsqu'ils assemblent leurs contenus. L'API Web Audio nous permet de présenter ces informations de manière efficace et de les mettre à jour rapidement via un zoom ou un panoramique de la chronologie.

L'extrait de code ci-dessous montre comment procéder:

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

Nous transmettons cet assistant à l'élément stocké dans IndexedDB. Une fois l'opération terminée, nous mettrons à jour l'élément dans IndexedDB ainsi que notre propre cache.

Nous recueillons des données sur audioBuffer avec le constructeur AudioContext. Toutefois, comme nous n'effectuons pas le rendu sur le matériel de l'appareil, nous utilisons OfflineAudioContext pour effectuer le rendu dans un ArrayBuffer où nous stockerons les données d'amplitude.

L'API elle-même renvoie des données à un taux d'échantillonnage beaucoup plus élevé que nécessaire pour une visualisation efficace. C'est pourquoi nous sous-échantillonnons manuellement l'échantillon à 200 Hz. Cela nous a semblé suffisant pour générer des formes d'onde utiles et visuellement attrayantes.

WebCodecs

Pour certaines vidéos, les vignettes des pistes sont plus utiles pour la navigation chronologique que les formes d'onde. Cependant, la génération de vignettes est plus gourmande en ressources que la génération de formes d'onde.

Nous ne pouvons pas mettre en cache toutes les vignettes potentielles lors du chargement. Un décodage rapide lors d'un panoramique ou d'un zoom sur la timeline est donc essentiel pour une application performante et réactive. Le goulot d'étranglement pour obtenir un dessin fluide est le décodage des images. Jusqu'à récemment, nous le faisions à l'aide d'un lecteur vidéo HTML5. Cette approche n'était pas performante et nous avons souvent constaté une dégradation de la réactivité de l'application lors du rendu des frames.

Nous avons récemment adopté les WebCodecs, qui peuvent être utilisés dans les workers Web. Cela devrait améliorer notre capacité à dessiner des vignettes pour de grandes quantités de calques sans affecter les performances du thread principal. Tant que l'implémentation du nœud de calcul Web est toujours en cours, vous trouverez ci-dessous un aperçu de notre implémentation actuelle du thread principal.

Un fichier vidéo contient plusieurs flux (vidéo, audio, sous-titres, etc.) qui sont "multiusés". Pour utiliser WebCodecs, nous devons d'abord disposer d'un flux vidéo démuxé. Nous démux les fichiers MP4 avec la bibliothèque mp4box, comme indiqué ci-dessous:

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

Cet extrait fait référence à une classe demuxer, que nous utilisons pour encapsuler l'interface dans MP4Box. Nous accédons à nouveau à l'élément depuis IndexedDB. Ces segments ne sont pas nécessairement stockés dans l'ordre des octets, et la méthode appendBuffer renvoie le décalage du fragment suivant.

Voici comment nous décoderons une image vidéo:

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

La structure du démuxer est assez complexe et dépasse le cadre de cet article. Il stocke chaque image dans un tableau nommé samples. Nous utilisons le démuxeur pour trouver l'image clé précédente la plus proche de l'horodatage souhaité, qui correspond à l'endroit où nous devons commencer le décodage vidéo.

Les vidéos sont composées d'images complètes, appelées images clés ou iFrame, ainsi que d'images delta beaucoup plus petites, souvent appelées images p ou b. Le décodage doit toujours commencer à une image clé.

L'application décode les trames en:

  1. Instanciation du décodeur avec un rappel de sortie de frame
  2. Configurer le décodeur pour le codec et la résolution d'entrée spécifiques
  3. Créer un encodedVideoChunk à l'aide des données du démuxer
  4. En appelant la méthode decodeEncodedFrame.

Nous procédons ainsi jusqu'à ce que nous atteignions la trame avec l'horodatage souhaité.

Étape suivante

Nous définissons l'échelle de l'interface comme la capacité à maintenir une lecture précise et performante à mesure que les projets deviennent plus volumineux et plus complexes. Pour améliorer les performances, vous pouvez monter le moins de vidéos possible à la fois. Toutefois, dans ce cas, les transitions lentes et saccadées risquent d'être potentielles. Bien que nous ayons développé des systèmes internes pour mettre en cache les composants vidéo en vue de les réutiliser, le contrôle que les balises vidéo HTML5 peuvent fournir est limité.

À l'avenir, nous tenterons de lire tous les contenus multimédias à l'aide de WebCodecs. Cela nous permettrait d'être très précis sur les données à mettre en mémoire tampon, ce qui devrait nous aider à adapter les performances.

Nous pouvons également décharger plus efficacement les grands calculs du pavé tactile vers les nœuds de calcul Web, et nous pouvons être plus intelligents dans la récupération des fichiers et la prégénération des frames. Nous voyons de grandes opportunités pour optimiser les performances globales de nos applications et pour étendre les fonctionnalités à l'aide d'outils tels que WebGL.

Nous souhaitons poursuivre notre investissement dans TensorFlow.js, que nous utilisons actuellement pour la suppression intelligente de l'arrière-plan. Nous prévoyons d'exploiter TensorFlow.js pour d'autres tâches sophistiquées telles que la détection d'objets, l'extraction de caractéristiques, le transfert de style, etc.

À terme, nous sommes ravis de continuer à développer notre produit avec des performances et des fonctionnalités semblables à celles d'une application native sur un Web sans frais et ouvert.