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

Agora, os criadores podem editar conteúdo em 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 tem crescido rapidamente desde o início da pandemia. As pessoas estão passando mais tempo consumindo vídeos infinitos de alta qualidade em plataformas como TikTok, Instagram e YouTube. Criativos e pequenos proprietários de pequenas empresas em todo o mundo precisam de ferramentas rápidas e fáceis de usar para produzir conteúdo em vídeo.

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

Sobre o Kapwing

O Kapwing é um editor de vídeo colaborativo baseado na Web projetado principalmente para criativos casuais, como streamers de jogos, músicos, criadores de conteúdo do YouTube e memes. Ele também é um recurso essencial para proprietários de empresas que precisam de uma maneira fácil de produzir o 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 que pesquisaram com apenas um clique, sem o atrito de acessar uma app store e fazer o download de um app. Na Web, as pessoas podem pesquisar exatamente com qual tarefa precisam de ajuda e, depois, fazer isso.

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

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

Embora a Web ofereça vantagens exclusivas, ela também apresenta desafios distintos. O Kapwing precisa oferecer reprodução suave e precisa de projetos complexos e 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 nossas metas de recursos e desempenho.

IndexedDB

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

O IndexedDB nos permite oferecer armazenamento permanente, como o de um sistema de arquivos, para os usuários. O resultado é que mais de 90% das solicitações de mídia no app são atendidas localmente. Integrar o IndexedDB ao nosso sistema foi muito simples.

Confira 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. Usado para inicialização ou para atualizar nosso esquema quando necessário. Transmitimos callbacks de processamento de erros blocked e blocking, que são úteis para evitar problemas de usuários com sistemas instáveis.

Por fim, observe nossa definição de uma chave primária keyPath. No nosso caso, esse é um ID exclusivo que chamamos de mediaLibraryID. Quando um usuário adiciona uma mídia ao nosso sistema, seja pelo app de upload ou por uma extensão de terceiros, adicionamos a mídia à nossa biblioteca 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 o acesso ao 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 acessamos os arquivos. Confira abaixo nossa 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 os acessos ao IndexedDB. Embora o IndexedDB seja rápido, o acesso à memória local é mais rápido. Recomendamos essa abordagem, desde que você gerencie o tamanho do cache.

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

API Web Audio

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

O editor do Kapwing tem um menu de mídia, incluindo vários modelos e elementos personalizados, incluindo alguns modelos específicos para 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 visualização do vídeo; e mais recursos.

Esse é um vídeo no estilo YouTube, que é comum no nosso app. Como o usuário não se move muito pelo clipe, as miniaturas visuais das linhas do tempo não são tão úteis para navegar entre as seções. Por outro lado, a forma de onda do áudio mostra picos e quedas, com que geralmente correspondem ao tempo morto na gravação. Se você aumentar o zoom na linha do tempo, verá informações de áudio mais detalhadas com vales correspondentes a interrupções e pausas.

Nossa pesquisa com usuários mostra que os criadores geralmente são guiados por essas formas de onda à medida que mesclam o conteúdo. A API Web Audio nos permite apresentar essas informações de maneira eficiente e atualizar rapidamente em um zoom ou movimentação da linha do tempo.

O snippet abaixo demonstra como fazemos 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 recurso armazenado no IndexedDB. Após a conclusão, atualizaremos o recurso no IndexedDB, bem como nosso próprio cache.

Coletamos dados sobre o audioBuffer com o construtor AudioContext, mas, como não estamos renderizando o hardware do dispositivo, usamos o OfflineAudioContext para renderizar em um ArrayBuffer, onde armazenaremos dados de amplitude.

A própria API retorna dados a uma taxa de amostragem muito maior do que o necessário para uma visualização eficaz. É por isso que reduzimos a amostragem manualmente para 200 Hz, o que consideramos suficiente para formas de onda úteis e visualmente atraentes.

WebCodecs

Em alguns vídeos, as miniaturas das faixas são mais úteis para a navegação na linha do tempo do que as formas de onda. No entanto, gerar miniaturas exige mais recursos que gerar formas de onda.

Não podemos armazenar em cache todas as miniaturas possíveis no carregamento. Por isso, a decodificação rápida na movimentação/zoom da linha do tempo é essencial para um aplicativo responsivo e de alto desempenho. O gargalo para conseguir o desenho suave de frames é a decodificação de frames, o que, até recentemente, fazíamos usando um player de vídeo HTML5. O desempenho dessa abordagem não era confiável, e geralmente observamos uma queda na capacidade de resposta do app durante a renderização do frame.

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

Um arquivo de vídeo contém vários streams: vídeo, áudio, legendas e assim por diante, que são combinadas. Para usar o WebCodecs, primeiro precisamos ter um stream de vídeo com multiplexação reduzida. Usamos a biblioteca mp4box para remover mp4s, 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 a interface de MP4Box. Acessamos novamente o recurso de IndexedDB. Esses segmentos não são necessariamente armazenados na ordem de bytes, e o método appendBuffer retorna o deslocamento da próxima parte.

Confira como decodificamos 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 método de multiplexação é bastante complexa e está além do escopo deste artigo. Ele armazena cada frame em uma matriz chamada samples. Usamos o demuxer para encontrar o frame-chave anterior mais próximo do carimbo de data/hora desejado, que é onde precisamos iniciar a decodificação do vídeo.

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

O aplicativo decodifica frames da seguinte maneira:

  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. Criar um encodedVideoChunk usando dados do demuxer.
  4. Chamando o método decodeEncodedFrame.

Fazemos isso até chegar ao frame com o carimbo de data/hora desejado.

Qual é a próxima etapa?

Definimos escala em nosso front-end como a capacidade de manter uma reprodução precisa e de alto desempenho à medida que os projetos se tornam maiores e mais complexos. Uma maneira de aumentar a performance é montar o menor número possível de vídeos de uma só vez. No entanto, quando fazemos isso, corremos o risco de transições lentas e entrecortadas. Embora tenhamos desenvolvido sistemas internos para armazenar componentes de vídeo em cache para reutilização, há limitações para a quantidade de controle que as tags de vídeo HTML5 podem oferecer.

No futuro, poderemos tentar reproduzir todas as mídias usando o WebCodecs. Isso nos permite ser muito precisos sobre quais dados são armazenados em buffer, o que ajuda a aumentar o desempenho.

Também podemos fazer um trabalho melhor de descarregamento de grandes cálculos do trackpad para workers da Web e podemos ser mais inteligentes ao pré-buscar arquivos e pré-gerar frames. Vemos grandes oportunidades para otimizar o desempenho geral dos nossos aplicativos e estender a funcionalidade com ferramentas como o WebGL.

Gostaríamos de continuar nosso investimento no TensorFlow.js, que atualmente usamos para remoção inteligente do segundo plano. Planejamos aproveitar o TensorFlow.js para outras tarefas sofisticadas, como detecção de objetos, extração de atributos, transferência de estilo e assim por diante.

Por fim, estamos animados para continuar criando nosso produto com desempenho e funcionalidade semelhantes ao nativo em uma Web livre e aberta.