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 di 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 senza costi, aggiungere nuovi livelli di video di stock senza costi, inserire sottotitoli codificati, trascrivere i video e caricare musica di sottofondo.

In che modo Kapwing offre la modifica e la collaborazione in tempo reale sul web

Sebbene il web offra vantaggi unici, presenta anche delle 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 in genere accedono a un contenuto una sola volta, i nostri clienti riutilizzano spesso le risorse, anche giorni e mesi dopo il caricamento.

IndexedDB ci consente di fornire ai nostri utenti uno spazio di archiviazione persistente simile a quello del sistema di file. 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 boilerplate 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, se 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 unico chiamato 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 una nostra struttura di dati, idbCache, che viene 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 a condizione che tu gestisca 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 dell'audio è estremamente importante per il montaggio video. Per capire meglio il motivo, 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 nel clip, quindi le miniature visive della sequenza temporale non sono molto utili per spostarsi 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ù dettagliate con valli corrispondenti a balbuzie e interruzioni.

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 in modo efficiente e di aggiornarle rapidamente con lo zoom o la panoramica della sequenza temporale.

Lo snippet di seguito mostra come:

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

A questo helper viene passato 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 più elevata di 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 decodifica rapida su panoramica/zoom della sequenza temporale è fondamentale per un'applicazione performante 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 che vengono "uniti". Per utilizzare WebCodecs, dobbiamo prima avere uno stream video demuxato. Demuxiamo i file mp4 con la libreria mp4box, come mostrato di seguito:

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 memorizzati in ordine di byte e il metodo appendBuffer restituisce l'offset del chunk 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. Memorizza ogni frame in un array denominato samples. Utilizziamo il demuxer per trovare il frame chiave precedente più vicino al timestamp desiderato, ovvero il punto in cui dobbiamo iniziare la decodifica video.

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

L'applicazione decodifica i fotogrammi:

  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 con un buon 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 in termini di prelievo antecipado di file e 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 il rilevamento di oggetti, l'estrazione di funzionalità, il trasferimento di stili 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 senza costi e aperto.