Kapwing: potente editing video per il Web

Ora i creator possono modificare contenuti video di alta qualità sul web con Kapwing, grazie a potenti API (come IndexedDB e WebCodecs) e strumenti per il rendimento.

Joshua Grossberg
Joshua Grossberg

Il consumo di video online è cresciuto rapidamente dall'inizio della pandemia. Le persone trascorrono più tempo a guardare video di alta qualità su piattaforme come TikTok, Instagram e YouTube. Creativi e proprietari di piccole imprese di tutto il mondo hanno bisogno di strumenti rapidi e facili da usare per creare contenuti video.

Aziende come Kapwing consentono di creare tutti questi contenuti video direttamente sul web, utilizzando le API e gli strumenti di rendimento più recenti e potenti.

Informazioni su Kapwing

Kapwing è un editor video collaborativo basato sul web progettato principalmente per creativi occasionali come game-streamer, musicisti, creator di YouTube e creator di meme. È inoltre una risorsa di riferimento per i proprietari di attività che hanno bisogno di un modo semplice per produrre i propri contenuti social, ad esempio gli annunci di Facebook e Instagram.

Le persone scoprono Kapwing cercando una determinata azione, ad esempio "come tagliare un video", "aggiungere musica al mio video" o "ridimensionare un video". Possono eseguire l'azione che stavano cercando con un solo clic, senza dover visitare un app store e scaricare un'app. Il web consente alle persone di cercare facilmente la specifica attività per cui hanno bisogno di aiuto e poi di eseguirla.

Dopo il primo clic, gli utenti di Kapwing possono fare molto di più. Possono esplorare modelli gratuiti, aggiungere nuovi livelli di video di stock gratuiti, inserire sottotitoli codificati, trascrivere i video e caricare musica di sottofondo.

Kapwing porta la modifica e la collaborazione in tempo reale sul web

Sebbene il web offra vantaggi esclusivi, presenta anche sfide distinte. Kapwing deve garantire la riproduzione fluida e precisa di progetti complessi e su più livelli su una vasta gamma di dispositivi e condizioni di rete. Per raggiungere questo obiettivo, utilizziamo una serie di API web per raggiungere i nostri obiettivi di prestazioni e funzionalità.

IndexedDB

Il montaggio ad alte prestazioni richiede che tutti i contenuti dei nostri utenti siano disponibili sul client, evitando la rete, se possibile. A differenza di un servizio di streaming, in cui gli utenti di solito accedono a un contenuto una volta, i nostri clienti riutilizzano le loro risorse frequentemente, giorni e persino mesi dopo il caricamento.

IndexedDB ci consente di fornire ai nostri utenti uno spazio di archiviazione permanente simile a un file system. Il risultato è che oltre il 90% delle richieste di contenuti multimediali nell'app viene soddisfatto localmente. L'integrazione di IndexedDB nel nostro sistema è stata molto semplice.

Ecco un codice di inizializzazione del boilerplate che viene eseguito al carico dell'app:

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

Passiamo una versione e definiamo una funzione upgrade. Viene utilizzato per l'inizializzazione o per aggiornare il nostro schema quando necessario. Trasmettiamo callback per la gestione degli errori, blocked e blocking, che abbiamo trovato utili per evitare problemi agli utenti con sistemi instabili.

Infine, tieni presente la nostra definizione di chiave primaria keyPath. Nel nostro caso, si tratta di un ID univoco che chiamiamo mediaLibraryID. Quando un utente aggiunge un contenuto multimediale al nostro sistema, tramite il nostro caricamento o un'estensione di terze parti, lo aggiungiamo alla nostra raccolta multimediale con il seguente codice:

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 è la nostra funzione definita internamente che esegue la serializzazione dell'accesso a IndexedDB. Questo è necessario per qualsiasi operazione di tipo di lettura, modifica e scrittura, poiché l'API IndexedDB è asincrona.

Ora vediamo come accediamo ai file. Di seguito è riportata la nostra funzione 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;
}

Abbiamo la nostra struttura di dati, idbCache, utilizzata per ridurre al minimo gli accessi a IndexedDB. Anche se IndexedDB è veloce, l'accesso alla memoria locale è ancora più veloce. Ti consigliamo questo approccio purché gestisci le dimensioni della cache.

L'array subscribers, utilizzato per impedire l'accesso simultaneo a IndexedDB, sarebbe altrimenti comune al caricamento.

API Web Audio

La visualizzazione audio è estremamente importante per l'editing video. Per capire il perché, dai un'occhiata a uno screenshot dell'editor:

L&#39;editor di Kapwing ha un menu per i contenuti multimediali, tra cui diversi modelli ed elementi personalizzati, inclusi alcuni modelli specifici per determinate piattaforme come LinkedIn; una sequenza temporale che separa video, audio e animazione; un editor di canvas con opzioni di qualità di esportazione; un&#39;anteprima del video e altre funzionalità.

Si tratta di un video in stile YouTube, molto comune nella nostra app. L'utente non si muove molto durante il clip, quindi le miniature visive della sequenza temporale non sono molto utili per navigare tra le sezioni. D'altra parte, la forma d'onda audio mostra picchi e valli, con le valli che in genere corrispondono ai tempi morti nella registrazione. Se aumenti lo zoom sulla sequenza temporale, vedrai informazioni audio più granulari con valli corrispondenti a stuttering e pause.

La nostra ricerca sugli utenti mostra che i creator si basano spesso su queste forme d'onda per montare i propri contenuti. L'API web audio ci consente di presentare queste informazioni con prestazioni elevate e di aggiornarle rapidamente con lo zoom o la panoramica della sequenza temporale.

Lo snippet seguente mostra come facciamo:

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

Trasmettiamo a questo helper l'asset memorizzato in IndexedDB. Al termine, aggiorneremo la risorsa in IndexedDB e nella nostra cache.

Raccogliamo i dati relativi a audioBuffer con il costruttore AudioContext, ma poiché non eseguiamo il rendering sull'hardware del dispositivo, utilizziamo OfflineAudioContext per eseguire il rendering in un ArrayBuffer in cui memorizzeremo i dati di Amplitude.

L'API stessa restituisce i dati a una frequenza di campionamento molto superiore a quella necessaria per una visualizzazione efficace. Per questo motivo, eseguiamo manualmente il downsampling a 200 Hz, che abbiamo riscontrato essere sufficiente per ottenere forme d'onda utili e visivamente accattivanti.

WebCodecs

Per alcuni video, le miniature delle tracce sono più utili per la navigazione nella sequenza temporale rispetto alle forme d'onda. Tuttavia, la generazione di miniature richiede più risorse rispetto alla generazione di forme d'onda.

Non possiamo memorizzare nella cache ogni potenziale miniatura al caricamento, quindi la rapida decodifica in panoramica/zoom della sequenza temporale è fondamentale per un'applicazione con prestazioni elevate e reattiva. Il bottleneck per ottenere un disegno fluido dei frame è la decodifica dei frame, che fino a poco tempo fa utilizzavamo con un video player HTML5. Il rendimento di questo approccio non era affidabile e spesso abbiamo riscontrato un calo della reattività dell'app durante il rendering dei frame.

Di recente abbiamo adottato WebCodecs, che può essere utilizzato nei worker web. In questo modo, potremo migliorare la nostra capacità di disegnare miniature per un gran numero di livelli senza influire sulle prestazioni del thread principale. Anche se l'implementazione dei web worker è ancora in corso, di seguito forniamo una panoramica della nostra implementazione esistente del thread principale.

Un file video contiene più stream (video, audio, sottotitoli e così via) "mussi". Per usare WebCodec, devi prima avere uno stream video demuxato. Demuxiamo i file mp4 con la libreria mp4box, come mostrato qui:

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

Questo snippet fa riferimento a una classe demuxer, che utilizziamo per incapsulare l'interfaccia di MP4Box. Accediamo di nuovo all'asset da IndexedDB. Questi segmenti non sono necessariamente archiviati in ordine di byte e il metodo appendBuffer restituisce l'offset del blocco successivo.

Ecco come decodifichiamo un fotogramma video:

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 struttura del demuxer è piuttosto complessa ed esula dall'ambito di questo articolo. Archivia ogni frame in un array chiamato samples. Utilizziamo il demuxer per trovare il frame chiave precedente più vicino al timestamp desiderato, che è il punto in cui dobbiamo iniziare la decodifica del video.

I video sono composti da frame completi, noti come fotogrammi chiave o i-frame, nonché da frame delta molto più piccoli, spesso chiamati anche fotogrammi p o b. La decodifica deve sempre iniziare da un frame chiave.

L'applicazione decodifica i frame tramite:

  1. Inizializzazione del decodificatore con un callback di output del frame.
  2. Configurazione del decodificatore per il codec e la risoluzione di input specifici.
  3. Creazione di un encodedVideoChunk utilizzando i dati del demuxer.
  4. Chiamata al metodo decodeEncodedFrame.

Continuiamo fino a raggiungere il frame con il timestamp desiderato.

Passaggi successivi

Per scalabilità nel nostro frontend intendiamo la capacità di mantenere un'esperienza di riproduzione precisa e di alto rendimento man mano che i progetti diventano più grandi e complessi. Un modo per migliorare le prestazioni è montare il minor numero possibile di video contemporaneamente, ma se lo facciamo rischiamo di avere transizioni lente e discontinue. Abbiamo sviluppato sistemi interni per memorizzare nella cache i componenti video al fine di riutilizzarli, ma il controllo che i tag video HTML5 possono fornire è limitato.

In futuro, potremmo tentare di riprodurre tutti i contenuti multimediali utilizzando WebCodecs. In questo modo potremmo essere molto precisi su quali dati mettere in memoria, il che dovrebbe contribuire a migliorare il rendimento.

Possiamo anche fare di meglio per scaricare calcoli complessi del trackpad su worker web e possiamo essere più intelligenti per quanto riguarda il pre-recupero di file e la pre-generazione di frame. Vediamo grandi opportunità per ottimizzare il rendimento complessivo delle nostre applicazioni ed estendere le funzionalità con strumenti come WebGL.

Vogliamo continuare a investire in TensorFlow.js, che al momento utilizziamo per la rimozione intelligente dello sfondo. Prevediamo di sfruttare TensorFlow.js per altre attività sofisticate come rilevamento di oggetti, estrazione di caratteristiche, trasferimento di stile e così via.

In definitiva, siamo entusiasti di continuare a sviluppare il nostro prodotto con funzionalità e prestazioni simili a quelle native su un web gratuito e aperto.