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 (comme IndexedDB et WebCodecs) et à des outils de performances.

Joshua Grossberg
Joshua Grossberg

La consommation de vidéos en ligne a augmenté rapidement depuis le début de la pandémie. Les utilisateurs passent plus de temps à visionner des vidéos haute qualité sans fin plates-formes comme TikTok, Instagram et YouTube. Créations et petites entreprises Les propriétaires du monde entier ont besoin d'outils rapides et faciles à utiliser pour réaliser des vidéos de votre contenu.

Des entreprises comme Kapwing permettent de créer tout ce contenu vidéo sur le Web, à l'aide des dernières API performantes et des derniers outils de performances.

À propos de Kapwing

Kapwing est un éditeur vidéo collaboratif basé sur le Web, conçu principalement pour les créateurs occasionnels tels que les streamers de jeux, les musiciens, les créateurs YouTube et les créateurs de mèmes. Il est C'est également une ressource incontournable pour les chefs d'entreprise qui recherchent un moyen simple de produire leurs propres contenus sur les réseaux sociaux, comme les publicités Facebook et Instagram.

Les utilisateurs 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 ont recherché en un seul clic, sans avoir à accéder à une plate-forme de téléchargement d'applications et à télécharger une application. Le Web permet aux utilisateurs de rechercher précisément la tâche à laquelle ils ont besoin d'aide, puis de l'effectuer.

Après ce premier clic, les utilisateurs de Kapwing peuvent faire bien plus. Ils peuvent explorez des modèles sans frais, ajoutez des calques de vidéos sans frais, insérez des sous-titres, transcrire des vidéos et importer une musique de fond.

Avec Kapwing, l'édition et la collaboration en temps réel sur le Web

Si le Web offre des avantages uniques, il présente également des les défis à relever. Kapwing doit pouvoir lire les images complexes, des projets multicouches sur un large éventail d'appareils et de conditions de réseau. Pour cela, nous utilisons diverses API Web afin d'atteindre nos objectifs de performances les objectifs des caractéristiques.

IndexedDB

L'édition haute performance exige de tous nos utilisateurs en ligne sur le en évitant d'utiliser le réseau autant que possible. Contrairement à un service de streaming, Lorsque les utilisateurs accèdent généralement à un contenu une seule fois, nos clients réutilisent leurs éléments, plusieurs jours, voire plusieurs mois après leur mise en ligne.

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

Voici un code d'initialisation standard qui s'exécute lors du 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. Cette méthode est utilisée pour l'initialisation ou pour mettre à jour notre schéma si nécessaire. Nous transmettons des informations de gestion des exceptions de rappel, blocked et blocking, que nous avons jugés utiles dans d’éviter les problèmes pour les utilisateurs dont les systèmes sont instables.

Enfin, notez notre définition d'une clé primaire keyPath. Dans notre cas, il s'agit un identifiant unique appelé mediaLibraryID. Lorsqu'un utilisateur ajoute un contenu 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 Accès IndexedDB. Ceci est obligatoire pour toute opération de type lecture-modification-écriture, car l'API IndexedDB est asynchrone.

Voyons maintenant comment accéder aux fichiers. Voici la 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 minimiser IndexedDB d'accès. Alors que IndexedDB est rapide, l'accès à la mémoire locale est plus rapide. Nous vous recommandons cette approche, à condition que vous gériez la taille du cache.

Le tableau subscribers, qui permet d'empêcher tout accès simultané à "IndexedDB", qui seraient sinon courants lors du chargement.

API Web Audio

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

L&#39;éditeur de Kapwing dispose d&#39;un menu pour les médias, 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 ; éditeur de canevas avec options de qualité d&#39;exportation un aperçu de la vidéo ; et d&#39;autres fonctionnalités.

Il s'agit d'une vidéo de style YouTube, qui est courante dans notre application. L'utilisateur n'a pas bougent beaucoup tout au long de l'extrait. Par conséquent, les miniatures visuelles de la timeline ne sont pas utiles pour naviguer entre les sections. D'autre part, l'audio waveform montre les pics et les creux, avec les vallées correspondant généralement aux temps morts de l'enregistrement. Si vous faire un zoom avant sur la timeline, vous verriez des informations audio plus détaillées avec creux correspondant aux stutterings et aux pauses.

Nos recherches sur l'expérience utilisateur montrent que les créateurs sont souvent guidés par ces formes d'onde ils assemblent leur contenu. L'API Web Audio nous permet de présenter les informations et de les mettre à jour rapidement en effectuant un zoom ou un panoramique calendrier.

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 outil d'aide l'élément stocké dans IndexedDB. Une fois l'opération terminée, mettra à jour l'élément dans IndexedDB ainsi que notre propre cache.

Nous collectons des données sur audioBuffer à l'aide du constructeur AudioContext. mais comme nous n'effectuons pas le rendu sur le matériel de l'appareil, nous utilisons OfflineAudioContext pour s'afficher dans un ArrayBuffer où nous allons stocker 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 à 200 Hz, que nous nous trouvons suffisant pour créer des formes d'onde utiles et visuellement attrayantes.

WebCodecs

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

Il est impossible de mettre en cache toutes les miniatures potentielles lors du chargement. Décodez donc rapidement la timeline. Le panoramique/zoom est essentiel pour une application performante et réactive. La pour obtenir des images fluides est le décodage des images. nous avons récemment utilisé un lecteur vidéo HTML5. Les performances de cette approche n'était pas fiable et nous avons souvent constaté une dégradation de la réactivité de l'application pendant le frame le rendu.

Nous avons récemment adopté WebCodecs, qui peut être utilisé dans les nœuds de calcul Web. Cela devrait améliorer notre capacité à dessiner des miniatures pour de grandes quantités de calques sans affecter les performances du thread principal. Bien que l'implémentation du thread de travail Web soit toujours en cours, nous vous présentons ci-dessous un aperçu de notre implémentation du thread principal existant.

Un fichier vidéo contient plusieurs flux: vidéo, audio, sous-titres, etc. sont "muxed" ensemble. Pour utiliser WebCodecs, nous devons d'abord disposer d'un flux vidéo démultiplexé. Nous démultiplexons 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 de 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 segment suivant.

Voici comment nous décodons un frame 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 demuxer est assez complexe et dépasse le cadre de ce . Il stocke chaque frame dans un tableau intitulé samples. Nous utilisons le démultiplexeur pour trouver la trame clé précédente la plus proche de l'horodatage souhaité, 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 des trames delta plus petites, souvent appelées cadres p ou b. Le décodage doit toujours commencer à un frame clé.

L'application décode les images en procédant comme suit :

  1. Instancier le décodeur avec un rappel de sortie de trame
  2. Configuration du décodeur pour le codec spécifique et la résolution d'entrée.
  3. Création d'un encodedVideoChunk à l'aide des données du démultiplexeur.
  4. En appelant la méthode decodeEncodedFrame.

Nous répétons cette opération jusqu'à ce que nous atteignions le frame avec le code temporel souhaité.

Étape suivante

Nous définissons l'évolutivité de l'interface comme la capacité à maintenir un niveau de précision une lecture plus performante à mesure que les projets deviennent plus volumineux et plus complexes. Une méthode d'évolutivité consiste à monter le moins de vidéos possible à la fois. les transitions sont lentes et saccadées. Bien que nous ayons développé des systèmes internes pour mettre en cache les composants vidéo à des fins de réutilisation, le contrôle que les balises vidéo HTML5 peuvent fournir est limité.

Nous essaierons peut-être à l'avenir de lire tous les contenus multimédias à l'aide de WebCodecs. Cela pourrait Nous pouvons ainsi être très précis sur les données à mettre en mémoire tampon, ce qui nous permet des performances.

Nous pouvons aussi mieux décharger les calculs de grands pavés tactiles vers nœuds de calcul Web, qui nous permettent de précharger plus intelligemment et la prégénération de frames. Il existe de nombreuses opportunités d'optimiser les performances globales de l'application et d'en étendre les fonctionnalités à l'aide d'outils tels que WebGL :

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

Enfin, nous sommes ravis de continuer à développer notre produit avec des performances et des fonctionnalités sur un Web libre et ouvert.