Kapwing: Potente editor de videos para la Web

Ahora, los creadores pueden editar contenido de video de alta calidad en la Web con Kapwing gracias a sus potentes APIs (como IndexedDB y WebCodecs) y herramientas de rendimiento.

Joshua Grossberg
Joshua Grossberg

El consumo de videos en línea aumentó rápidamente desde el comienzo de la pandemia. Los usuarios pasan cada vez más tiempo viendo videos infinitos de alta calidad en plataformas como TikTok, Instagram y YouTube. Los creativos y propietarios de pequeñas empresas de todo el mundo necesitan herramientas rápidas y fáciles de usar para crear contenido de video.

Empresas como Kapwing posibilitan la creación de todo este contenido de video en la Web mediante lo más reciente en APIs potentes y herramientas de rendimiento.

Acerca de Kapwing

Kapwing es un editor de video colaborativo basado en la Web diseñado principalmente para creadores casuales, como quienes transmiten juegos, músicos, creadores de YouTube y meme. También es un recurso recomendado para los propietarios de empresas que necesitan una manera fácil de producir su propio contenido social, como los anuncios de Instagram y Facebook.

Las personas descubren Kapwing cuando buscan una tarea específica, como “cómo cortar un video”, “agregar música a mi video” o “cambiar el tamaño de un video”. Pueden hacer lo que buscaron con un solo clic, sin la fricción adicional de navegar a una tienda de aplicaciones y descargar una app. La Web facilita a las personas buscar precisamente con qué tarea necesitan ayuda y luego realizarla.

Después de ese primer clic, los usuarios de Kapwing pueden hacer mucho más. Pueden explorar plantillas gratuitas, agregar capas nuevas de videos de archivo gratuitos, insertar subtítulos, transcribir videos y subir música de fondo.

Cómo Kapwing lleva la edición en tiempo real y la colaboración a la Web

Si bien la Web proporciona ventajas únicas, también presenta desafíos diferentes. Kapwing necesita proporcionar una reproducción fluida y precisa de proyectos complejos de varias capas en una amplia variedad de dispositivos y condiciones de red. Para lograrlo, usamos una variedad de APIs web para alcanzar nuestros objetivos de rendimiento y funciones.

IndexedDB

La edición de alto rendimiento requiere que todo el contenido de nuestros usuarios esté alojado en el cliente y evita la red siempre que sea posible. A diferencia de un servicio de transmisión, en el que los usuarios suelen acceder a un contenido una vez, los clientes reutilizan sus activos con frecuencia, días o incluso meses después de que lo subes.

IndexedDB nos permite proporcionarles a nuestros usuarios almacenamiento persistente similar a un sistema de archivos. Como resultado, más del 90% de las solicitudes de contenido multimedia de la app se entregan de forma local. Integrar IndexedDB en nuestro sistema fue muy sencillo.

Este es un código de inicialización de la placa de caldera que se ejecuta cuando se carga la 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');
      },
    }
  );

Pasamos una versión y definimos una función upgrade. Se usa para inicializar o actualizar nuestro esquema cuando sea necesario. Pasamos las devoluciones de llamada de manejo de errores, blocked y blocking, que son útiles para prevenir problemas de los usuarios con sistemas inestables.

Por último, observa nuestra definición de clave primaria keyPath. En nuestro caso, se trata de un ID único que llamamos mediaLibraryID. Cuando un usuario agrega contenido multimedia a nuestro sistema, ya sea mediante la persona que lo subió o una extensión de terceros, lo agregamos a nuestra biblioteca multimedia con el siguiente 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 es nuestra propia función definida de forma interna que serializa el acceso a IndexedDB. Esto es necesario para cualquier operación de tipo de lectura, modificación y escritura, ya que la API de IndexedDB es asíncrona.

Ahora, veamos cómo accedemos a los archivos. A continuación, se muestra nuestra función 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;
}

Tenemos nuestra propia estructura de datos, idbCache, que se usa para minimizar los accesos a IndexedDB. Si bien IndexedDB es rápido, el acceso a la memoria local es más rápido. Recomendamos este enfoque, siempre y cuando administres el tamaño de la caché.

De lo contrario, el arreglo subscribers, que se usa para evitar el acceso simultáneo a IndexedDB, sería común en la carga.

API de Web Audio

La visualización de audio es increíblemente importante para la edición de video. Para comprender por qué, echa un vistazo a una captura de pantalla del editor:

El editor de Kapwing tiene un menú para contenido multimedia que incluye varias plantillas y elementos personalizados, incluidas algunas que son específicas de ciertas plataformas como LinkedIn; una línea de tiempo que separa video, audio y animación; editor de lienzo con opciones de calidad de exportación; vista previa del video, y más capacidades.

Este es un video al estilo de YouTube, lo cual es común en nuestra app. El usuario no se mueve mucho a lo largo del clip, por lo que las miniaturas visuales de las líneas de tiempo no son tan útiles para navegar entre secciones. Por otro lado, la forma de onda del audio muestra los picos y los valles, y estos suelen corresponder al tiempo muerto de la grabación. Si acercas el cronograma, verás información de audio más detallada con valles que corresponden a saltos y pausas.

Nuestra investigación sobre usuarios demuestra que estas formas de onda suelen guiar a los creadores cuando dividen su contenido. La API de audio web nos permite presentar esta información de manera eficaz y actualizar rápidamente con un zoom o un desplazamiento lateral en la línea de tiempo.

El siguiente fragmento demuestra cómo lo hacemos:

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

Pasamos a este auxiliar el recurso almacenado en IndexedDB. Cuando termine, actualizaremos el recurso en IndexedDB, así como nuestra propia caché.

Recopilamos datos sobre el objeto audioBuffer con el constructor AudioContext, pero, como no hacemos la renderización en el hardware del dispositivo, usamos OfflineAudioContext para renderizarlos en una ArrayBuffer, donde almacenaremos datos de amplitud.

La API muestra los datos a una tasa de muestreo mucho más alta que la necesaria para una visualización efectiva. Es por eso que bajamos manualmente el muestreo a 200 Hz, que resulta ser suficiente para formas de onda útiles y visualmente atractivas.

WebCodecs

En algunos videos, las miniaturas de las pistas son más útiles para la navegación en el cronograma que las formas de onda. Sin embargo, la generación de miniaturas requiere más recursos que la generación de formas de onda.

No podemos almacenar en caché todas las miniaturas potenciales durante la carga, por lo que la decodificación rápida del desplazamiento lateral y zoom en el cronograma es fundamental para una aplicación responsiva y de buen rendimiento. El cuello de botella para lograr un dibujo de fotogramas fluido es la decodificación de fotogramas, algo que hasta hace poco se hacía con un reproductor de video HTML5. El rendimiento de ese enfoque no era confiable y, con frecuencia, se degradaba la capacidad de respuesta de la app durante la renderización de fotogramas.

Recientemente, nos trasladamos a WebCodecs, que se puede usar en los trabajadores web. Esto debería mejorar nuestra capacidad de dibujar miniaturas para grandes cantidades de capas sin afectar el rendimiento del subproceso principal. Mientras la implementación del trabajador web aún está en curso, a continuación te mostramos un esquema de la implementación del subproceso principal existente.

Un archivo de video contiene varias transmisiones: video, audio, subtítulos, etc., que se “combinan” en conjunto. Para usar WebCodecs, primero necesitamos una transmisión de video con demux. Quitamos la combinación de archivos mp4 con la biblioteca de mp4box, como se muestra a continuación:

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

Este fragmento hace referencia a una clase demuxer, que usamos para encapsular la interfaz en MP4Box. Accedemos una vez más al recurso desde IndexedDB. Estos segmentos no necesariamente se almacenan en orden de bytes, y el método appendBuffer muestra el desplazamiento del siguiente fragmento.

A continuación, te mostramos cómo decodificar un fotograma de 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 estructura del demuxer es bastante compleja y está fuera del alcance de este artículo. Almacena cada fotograma en un array llamado samples. Usamos el demuxer para encontrar el fotograma de clave anterior más cercano a la marca de tiempo deseada, que es donde debemos comenzar la decodificación de video.

Los videos constan de fotogramas completos, conocidos como fotogramas clave o i-frames, así como fotogramas delta mucho más pequeños, que a menudo se denominan fotogramas p o b. La decodificación siempre debe comenzar en un fotograma clave.

La aplicación decodifica fotogramas de la siguiente manera:

  1. Crear una instancia del decodificador con una devolución de llamada de salida de fotogramas
  2. Configuración del decodificador para el códec específico y la resolución de entrada.
  3. Se crea un encodedVideoChunk con datos del demuxer.
  4. Mediante una llamada al método decodeEncodedFrame

Hacemos esto hasta llegar al fotograma con la marca de tiempo deseada.

¿Qué sigue?

Definimos el escalamiento en nuestro frontend como la capacidad de mantener una reproducción precisa y de buen rendimiento a medida que los proyectos se hacen más grandes y más complejos. Una forma de escalar el rendimiento es activar la menor cantidad de videos posible al mismo tiempo. Sin embargo, cuando lo hacemos, es probable que se produzcan transiciones lentas y entrecortadas. Si bien hemos desarrollado sistemas internos que permiten almacenar en caché componentes de video para su reutilización, existen limitaciones en cuanto al control que pueden proporcionar las etiquetas de video HTML5.

En el futuro, es posible que intentemos reproducir todo el contenido multimedia con WebCodecs. Esto podría permitirnos ser muy precisos sobre qué datos almacenaremos en búfer, lo que debería ayudar a escalar el rendimiento.

También podemos realizar una mejor transferencia de los cálculos del panel táctil de gran tamaño a los trabajadores web y podemos ser más inteligentes respecto de la carga previa de archivos y la generación previa de fotogramas. Vemos grandes oportunidades para optimizar el rendimiento general de nuestra aplicación y extender la funcionalidad con herramientas como WebGL.

Nos gustaría seguir invirtiendo en TensorFlow.js, que actualmente usamos para la eliminación inteligente en segundo plano. Planeamos aprovechar TensorFlow.js para otras tareas sofisticadas como la detección de objetos, la extracción de atributos y la transferencia de diseño, entre otras.

En última instancia, nos emociona seguir desarrollando nuestro producto con un rendimiento y una funcionalidad nativos en una Web libre y abierta.