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 è aumentato rapidamente dall'inizio della pandemia. Le persone trascorrono sempre più tempo a consumare video di alta qualità su piattaforme come TikTok, Instagram e YouTube. In tutto il mondo, i creativi e i proprietari di piccole imprese 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 più avanzate e gli strumenti per le prestazioni più recenti.

Informazioni su Kapwing

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

Le persone scoprono Kapwing cercando un'attività specifica, ad esempio "come tagliare un video", "aggiungere musica al mio video" o "ridimensionare un video". Possono fare ciò che hanno cercato con un solo clic, senza il fastidio di dover visitare un app store e scaricare un'app. Il web consente alle persone di cercare facilmente l'attività per cui hanno bisogno di aiuto e poi farlo.

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

Come Kapwing porta sul web editing e collaborazione in tempo reale

Il web offre vantaggi unici, ma presenta anche sfide distinte. Kapwing deve garantire una riproduzione fluida e precisa di progetti complessi a più livelli su un'ampia gamma di dispositivi e condizioni di rete. A questo scopo, utilizziamo una serie di API web al fine di raggiungere i nostri obiettivi in termini di prestazioni e funzionalità.

IndexedDB

La modifica ad alte prestazioni richiede che tutti i contenuti dei nostri utenti siano online sul client, evitando la rete, se possibile. A differenza di un servizio di streaming, in cui gli utenti solitamente accedono a un contenuto una sola volta, i nostri clienti riutilizzano i loro asset spesso, giorni e persino mesi dopo il caricamento.

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

Ecco un codice di inizializzazione della piastra di caldaia che viene eseguito al caricamento 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 lo schema quando necessario. Abbiamo inoltrato i callback di gestione degli errori blocked e blocking, che abbiamo trovato utili per prevenire i problemi degli utenti con sistemi instabili.

Infine, prendi nota della nostra definizione di chiave primaria keyPath. Nel nostro caso, si tratta di un ID univoco chiamato mediaLibraryID. Quando un utente aggiunge un elemento multimediale al nostro sistema, tramite il nostro strumento di caricamento o un'estensione di terze parti, lo aggiungiamo alla nostra libreria 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 serializza l'accesso IndexedDB. Questo attributo è obbligatorio per qualsiasi operazione di tipo lettura, modifica e scrittura, poiché l'API IndexedDB è asincrona.

Ora diamo un'occhiata a come accediamo ai file. Di seguito è riportata la 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;
}

La nostra struttura di dati, idbCache, viene utilizzata per ridurre al minimo gli accessi a IndexedDB. Anche se IndexedDB è veloce, l'accesso alla memoria locale è più veloce. Ti consigliamo questo approccio, a patto che tu sia in grado di gestire le dimensioni della cache.

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

API Web Audio

La visualizzazione audio è incredibilmente importante per l'editing video. Per capire il motivo, guarda uno screenshot dell'editor:

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

Questo è un video in stile YouTube comune nella nostra app. L'utente non si sposta molto nel clip, quindi le miniature visive della sequenza temporale non sono utili per spostarsi da una sezione all'altra. La forma d'onda audio, invece, mostra picchi e valli, e le valli corrispondono in genere ai tempi morti della registrazione. Se aumenti lo zoom sulla sequenza temporale, vedrai informazioni sull'audio più granulari con valli corrispondenti a interruzioni e pause.

La nostra ricerca con gli utenti dimostra che i creator sono spesso guidati da queste forme d'onda quando suddividono i loro contenuti. L'API Web Audio ci consente di presentare queste informazioni in modo efficiente e di aggiornarle rapidamente con uno zoom o una panoramica della sequenza temporale.

Lo snippet riportato di seguito mostra come procediamo:

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 questo helper alla risorsa archiviata in IndexedDB. Al termine, aggiorneremo la risorsa in IndexedDB e la nostra cache.

Raccogliamo dati su audioBuffer con il costruttore AudioContext, ma poiché non eseguiamo il rendering sull'hardware del dispositivo, utilizziamo OfflineAudioContext per eseguire il rendering in un ArrayBuffer dove memorizziamo i dati di ampiezza.

L'API restituisce i dati con una frequenza di campionamento molto superiore a quella necessaria per una visualizzazione efficace. Ecco perché eseguiamo manualmente il downgrade a 200 Hz, che abbiamo trovato sufficiente per forme d'onda utili e visivamente accattivanti.

WebCodecs

Per alcuni video, le miniature delle tracce sono più utili per la navigazione nella sequenza temporale piuttosto che per le forme d'onda. Tuttavia, generare miniature richiede più risorse che generare forme d'onda.

Non è possibile memorizzare nella cache ogni potenziale miniatura al caricamento, pertanto la rapida decodifica in panoramica/zoom della sequenza temporale è fondamentale per un'applicazione dalle prestazioni elevate e reattiva. Il collo di bottiglia per ottenere un disegno uniforme dei fotogrammi è la decodifica dei frame, che fino a poco tempo fa utilizzando un video player HTML5. Le prestazioni di questo approccio non erano affidabili e spesso abbiamo riscontrato una riduzione della reattività dell'app durante il rendering dei frame.

Recentemente siamo passati a WebCodecs, che può essere utilizzato nei web worker. Ciò dovrebbe migliorare la nostra capacità di disegnare miniature per grandi quantità di livelli senza influire sulle prestazioni del thread principale. Mentre l'implementazione del web worker è ancora in corso, di seguito forniamo uno schema dell'implementazione esistente del thread principale.

Un file video contiene più stream: video, audio, sottotitoli e così via che vengono "combinati". Per utilizzare WebCodecs, dobbiamo prima avere uno stream video demuxto. Demuxiamo gli 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 in MP4Box. Accediamo di nuovo all'asset da IndexedDB. Questi segmenti non vengono necessariamente archiviati in ordine di byte e il metodo appendBuffer restituisce l'offset del blocco successivo.

Ecco come decodificare 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 e non rientra nell'ambito di questo articolo. Archivia ogni frame in un array chiamato samples. Utilizziamo il demuxer per trovare il fotogramma chiave precedente più vicino al timestamp desiderato, che è il punto in cui dobbiamo iniziare la decodifica video.

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

L'applicazione decodifica i frame per:

  1. Creare un'istanza del decoder con un callback di output del frame.
  2. Configurazione del decoder per il codec specifico e la risoluzione di ingresso.
  3. Creazione di un encodedVideoChunk utilizzando i dati del demuxer.
  4. Chiamata al metodo decodeEncodedFrame.

Lo facciamo fino a quando non raggiungiamo il frame con il timestamp desiderato.

Passaggi successivi

Nel nostro frontend definiamo la scalabilità come la capacità di mantenere una riproduzione precisa e ad alte prestazioni man mano che i progetti diventano più grandi e complessi. Un modo per scalare le prestazioni è montare il minor numero possibile di video contemporaneamente, tuttavia, quando lo facciamo, rischiamo di transizioni lente e discontinue. Abbiamo sviluppato sistemi interni per memorizzare nella cache i componenti video per il riutilizzo, ma esistono limitazioni riguardo al controllo che i tag video HTML5 possono fornire.

In futuro potremmo tentare di riprodurre tutti i contenuti multimediali utilizzando WebCodec. Questo potrebbe ci consente di essere molto precisi sui dati di cui eseguire il buffering, che dovrebbero contribuire a scalare le prestazioni.

Possiamo anche svolgere meglio il lavoro di trasferimento dei calcoli con trackpad di grandi dimensioni ai lavoratori web e possiamo essere più intelligenti riguardo al precaricamento dei file e alla pregenerazione di frame. Intravediamo grandi opportunità per ottimizzare le nostre prestazioni complessive delle applicazioni e per estendere la funzionalità con strumenti come WebGL.

Vorremmo continuare a investire in TensorFlow.js, che attualmente utilizziamo per la rimozione intelligente dello sfondo. Prevediamo di utilizzare 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 prestazioni e funzionalità simili a quelle native su un Web aperto e senza costi.