Kapwing: edição avançada de vídeos para a Web

Os criadores agora podem editar conteúdo de vídeo de alta qualidade na Web com o Kapwing, graças a APIs poderosas (como IndexedDB e WebCodecs) e ferramentas de desempenho.

Joshua Grossberg
Joshua Grossberg

O consumo de vídeos on-line cresceu rapidamente desde o início da pandemia. As pessoas estão passando mais tempo assistindo vídeos de alta qualidade plataformas como TikTok, Instagram e YouTube. Criativos e pequenas empresas proprietários de todo o mundo precisam de ferramentas rápidas e fáceis de usar para fazer conteúdo.

Empresas como a Kapwing possibilitam a criação de todo esse conteúdo em vídeo corretamente na Web usando as mais recentes APIs e ferramentas de desempenho poderosas.

Sobre o Kapwing

O Kapwing é um editor de vídeo colaborativo baseado na Web projetado principalmente para fins casuais criativos como usuários que fazem streaming de jogos, músicos, criadores de conteúdo do YouTube e memers. Está também é um recurso muito útil para proprietários de empresas que precisam de uma maneira fácil de produzir seu próprio conteúdo social, como anúncios no Facebook e no Instagram.

As pessoas descobrem o Kapwing ao pesquisar uma tarefa específica, por exemplo, "como cortar um vídeo", "adicionar música ao meu vídeo" ou "redimensionar um vídeo". Eles podem fazer o quê pesquisado com apenas um clique, sem a necessidade adicional de acessar uma app store e fazer o download de um app. A Web simplifica o processo que as pessoas pesquisem com qual tarefa precisam de ajuda e façam isso.

Depois desse primeiro clique, os usuários do Kapwing podem fazer muito mais. Eles podem explorar modelos sem custo financeiro, adicionar novas camadas de vídeos sem custo financeiro de bancos de imagens, inserir legendas, transcrever vídeos e enviar música de fundo.

Como o Kapwing leva a edição e a colaboração em tempo real para a Web

Embora a Web ofereça vantagens únicas, ela também apresenta diferentes e superar desafios comerciais. O Kapwing precisa oferecer uma reprodução suave e precisa de projetos complexos com várias camadas em uma ampla variedade de dispositivos e condições de rede. Para isso, usamos várias APIs da Web para alcançar nossos objetivos de desempenho e recursos.

IndexedDB

A edição de alto desempenho exige que todos os recursos disponível no cliente, evitando a rede sempre que possível. Ao contrário de um serviço de streaming, em que os usuários geralmente acessam um conteúdo uma vez, nossos clientes reutilizam os recursos com frequência, dias e até meses após o upload.

O IndexedDB nos permite fornecer dados persistentes para os usuários. O resultado é que mais de 90% das mídias no app são atendidas localmente. Integrar o IndexedDB à nossa sistema era muito simples.

Veja um código de inicialização padronizado que é executado no carregamento do 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');
      },
    }
  );

Vamos transmitir uma versão e definir uma função upgrade. Isso é usado para inicialização ou atualizar o esquema quando necessário. Passamos o tratamento de erros callbacks, blocked e blocking, que consideramos úteis em para evitar problemas para usuários com sistemas instáveis.

Por fim, observe nossa definição de uma chave primária keyPath. No nosso caso, um ID exclusivo, chamamos mediaLibraryID. Quando um usuário adiciona uma mídia ao nosso sistema, seja pelo usuário que fez o envio ou por uma extensão de terceiros, nós adicionamos a mídia à nossa biblioteca de mídia com o seguinte código:

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 é nossa própria função definida internamente que serializa Acesso IndexedDB. Isso é necessário para qualquer operação do tipo leitura-modificação-gravação, já que a API IndexedDB é assíncrona.

Agora vamos conferir como acessar arquivos. Confira abaixo a função 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;
}

Temos nossa própria estrutura de dados, idbCache, que é usada para minimizar o IndexedDB. acessos. Embora o IndexedDB seja rápido, o acesso à memória local é mais rápido. Qa essa abordagem é recomendada, desde que você gerencie o tamanho do cache.

A matriz subscribers, que é usada para impedir o acesso simultâneo ao IndexedDB, seria comum no carregamento.

API Web Audio

A visualização de áudio é extremamente importante para a edição de vídeos. Para entender Por que, veja uma captura de tela do editor:

O editor da Kapwing tem um menu para mídia, que inclui vários modelos e elementos personalizados, incluindo alguns modelos específicos de determinadas plataformas, como o LinkedIn. uma linha do tempo que separa vídeo, áudio e animação; editor de tela com opções de qualidade de exportação; uma prévia do vídeo; e mais recursos.

Esse é um vídeo no estilo do YouTube, o que é comum no nosso aplicativo. O usuário não se movem muito durante o clipe. Por isso, as miniaturas visuais da linha do tempo não são como útil para navegar entre as seções. Por outro lado, o áudio waveform mostra picos e quedas, com as quedas normalmente correspondentes ao tempo morto na gravação. Se você aumentar o zoom na linha do tempo, veria informações de áudio mais sutis com o vales correspondentes a gaguejos e pausas.

Nossa pesquisa de usuário mostra que os criadores de conteúdo geralmente são guiados por essas formas de onda ao dividir o conteúdo. A API de áudio da Web nos permite apresentar as informações com bom desempenho e atualizá-las rapidamente com zoom ou movimentação do na linha do tempo.

O snippet abaixo demonstra como fazer isso:

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

Passamos a esse auxiliar o ativo que está armazenado no IndexedDB. Após a conclusão, vamos atualizar o recurso no IndexedDB e no nosso próprio cache.

Coletamos dados sobre o audioBuffer com o construtor AudioContext. mas como não estamos renderizando para o hardware do dispositivo, usamos o OfflineAudioContext para renderizar em uma ArrayBuffer em que vamos armazenar e amplitude dos dados.

A API retorna dados com uma taxa de amostragem muito maior do que o necessário para visualização eficaz. É por isso que fazemos o rebaixamento manual para 200 Hz, que é ser o suficiente para formas de onda úteis e visualmente atraentes.

WebCodecs

Para certos vídeos, as miniaturas das faixas são mais úteis para a linha do tempo navegação do que as formas de onda. No entanto, gerar miniaturas é mais recurso mais intensa do que gerar formas de onda.

Não podemos armazenar em cache todas as miniaturas em potencial no carregamento. Portanto, faça uma decodificação rápida na linha do tempo. A movimentação/zoom é essencial para um aplicativo com bom desempenho e responsivo. O um gargalo para conseguir um desenho de frame suave é a decodificação de frames, o que até recentemente, usamos um player de vídeo HTML5. O desempenho dessa abordagem não era confiável e com frequência notamos uma queda na capacidade de resposta do app durante o frame renderização.

Recentemente, mudamos para o WebCodecs, que pode ser usado em os web workers. Isso deve melhorar nossa capacidade de desenhar miniaturas para quantidades de camadas sem afetar o desempenho da linha de execução principal. Enquanto a implementação do worker da Web ainda está em andamento, apresentamos abaixo um esboço da nossa implementação de linha de execução principal atual.

Um arquivo de vídeo contém vários streams: vídeo, áudio, legendas etc. são "combinados" juntas. Para usar o WebCodecs, primeiro precisamos ter um stream de vídeo desmuxado. Demux mp4s com a biblioteca mp4box, conforme mostrado aqui:

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

Esse snippet se refere a uma classe demuxer, que usamos para encapsular o para MP4Box. Acessamos novamente o ativo a partir do IndexedDB. Esses os segmentos não são necessariamente armazenados em ordem de bytes, e o appendBuffer retorna o deslocamento do próximo bloco.

Veja como decodificar um frame de vídeo:

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

A estrutura do demuxer é bastante complexa e está fora do escopo deste artigo. Ele armazena cada frame em uma matriz intitulada samples. Usamos o demuxer para encontrar o frame-chave precedente mais próximo do carimbo de data/hora desejado, que é em que precisamos iniciar a decodificação do vídeo.

Os vídeos são compostos de frames completos, conhecidos como chave ou i-frames, e de frames delta muito menores, geralmente chamados de p-frames ou b-frames. A decodificação precisa sempre começar em um frame-chave.

O aplicativo decodifica frames por:

  1. Instanciar o decodificador com um callback de saída de frame.
  2. Configurar o decodificador para o codec e a resolução de entrada específicos.
  3. Criação de um encodedVideoChunk usando dados do demuxer.
  4. Chamando o método decodeEncodedFrame.

Fazemos isso até chegar ao frame com a marcação de tempo desejada.

A seguir

Definimos escala em nosso front-end como a capacidade de manter reprodução com alto desempenho à medida que os projetos ficam maiores e mais complexos. Uma forma de escalonar desempenho é montar o menor número possível de vídeos de uma só vez. No entanto, quando há risco de transições lentas e entrecortadas. Embora tenhamos desenvolvido armazenamento em cache de componentes de vídeo para reutilização, que as tags de vídeo HTML5 podem fornecer.

No futuro, poderemos tentar reproduzir toda a mídia usando WebCodecs. Isso poderia nos permitem ter muita precisão sobre quais dados armazenamos em buffer, o que ajuda a escalonar desempenho.

Também podemos melhorar o descarregamento de cálculos grandes de trackpad para trabalhadores da Web, e podemos ser mais inteligentes com a pré-busca e a pré-geração de frames. Vemos grandes oportunidades para otimizar o desempenho geral do aplicativo e ampliar a funcionalidade com ferramentas como WebGL (link em inglês).

Gostaríamos de continuar nosso investimento TensorFlow.js, que usamos atualmente para remoção inteligente do plano de fundo. Planejamos usar o TensorFlow.js para outros tarefas sofisticadas, como detecção de objetos, extração de atributos, transferência de estilo etc.

Por fim, estamos animados para continuar criando nosso produto com desempenho e funcionalidade em uma Web aberta e sem custo financeiro.