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 las herramientas de rendimiento.

Joshua Grossberg
Joshua Grossberg

El consumo de videos en línea ha crecido rápidamente desde el comienzo de la pandemia. Las personas pasan más tiempo consumiendo un sinfín de videos de alta calidad en en plataformas como TikTok, Instagram y YouTube. Creatividades y pequeñas empresas propietarios de todo el mundo necesitan herramientas rápidas y fáciles de usar para crear videos contenido.

Empresas como Kapwing permiten crear todo este contenido de video directamente en la Web con las herramientas de rendimiento y las APIs potentes más recientes.

Acerca de Kapwing

Kapwing es un editor de video colaborativo basado en la Web, diseñado principalmente para los como transmisores de juegos, músicos, creadores de YouTube y creadores de memes. También es un recurso de referencia para los propietarios de empresas que necesitan una forma sencilla de producir su propio contenido de redes sociales, como anuncios de Facebook e Instagram.

Las personas descubren Kapwing cuando buscan una tarea específica, por ejemplo, "cómo recortar un video", "agregar música a mi video" o "cambiar el tamaño de un video". Pueden hacer qué buscaron con un solo clic, sin la fricción adicional de navegar a una tienda de aplicaciones y descargar una aplicación La Web simplifica que las personas busquen precisamente para qué tarea necesitan ayuda y, luego, la lleven a cabo.

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

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

Si bien la Web ofrece ventajas únicas, también presenta desafíos distintos. Kapwing debe ofrecer una reproducción fluida y precisa de proyectos complejos y de varias capas en una amplia variedad de dispositivos y condiciones de red. Para lograrlo, usamos una variedad de APIs web para lograr nuestro rendimiento y los objetivos de la función.

IndexedDB

Una edición de alto rendimiento requiere que todos los usuarios contenido publicado en y evitar la red siempre que sea posible. A diferencia de un servicio de transmisión, en la que los usuarios suelen acceder a un contenido una sola vez, nuestros clientes reutilizan sus recursos con frecuencia, días e incluso meses después de subirlos.

IndexedDB nos permite proporcionar almacenamiento continuo similar al sistema de archivos a nuestros usuarios. El resultado es que más del 90% de los medios las solicitudes en la aplicación se entregan de forma local. La integración de IndexedDB en nuestro sistema fue muy sencilla.

A continuación, se muestra un código de inicialización de la 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 o para actualizar el esquema cuando sea necesario. Pasamos el manejo de errores devoluciones de llamada, blocked y blocking, que nos resultaron útiles en y evitar problemas para los usuarios con sistemas inestables.

Por último, observa nuestra definición de keyPath de clave primaria. En nuestro caso, esta es una y el ID único que llamamos mediaLibraryID. Cuando un usuario agrega un contenido multimedia a nuestro sistema, ya sea a través de quien sube el video 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 internamente que serializa Acceso a IndexedDB. Esto es obligatorio para cualquier operación de tipo de lectura, modificación y escritura, ya que la API de IndexedDB es asíncrona.

Veamos cómo accedes 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 la base de datos indexada. a los datos de acceso. Si bien IndexedDB es rápido, el acceso a la memoria local también lo es. Mié recomendamos este enfoque, siempre y cuando administres el tamaño de la caché.

El array subscribers, que se usa para evitar el acceso simultáneo a IndexedDB. De lo contrario, serían comunes en la carga.

API de Web Audio

La visualización de audio es increíblemente importante para la edición de videos. Para comprender Observa una captura de pantalla del editor:

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

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

Nuestra investigación sobre usuarios demuestra que los creadores suelen guiarse por estas formas empalme su contenido. La API de audio web nos permite presentar este información sobre el rendimiento y para actualizar rápidamente en el cronograma.

En el siguiente fragmento, se 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 asistente el recurso que se almacena en IndexedDB. Al finalizar, el recurso en IndexedDB y en nuestra caché.

Recopilamos datos sobre audioBuffer con el constructor AudioContext, pero, como no procesamos en el hardware del dispositivo, usamos OfflineAudioContext para renderizar en un ArrayBuffer, en el que almacenaremos datos de amplitud.

La API en sí muestra datos a una tasa de muestreo mucho más alta de la necesaria para una visualización eficaz. Por eso, redujimos manualmente el muestreo a 200 Hz, que que son suficientes para formas de onda útiles y visualmente atractivas.

WebCodecs

Para ciertos videos, las miniaturas de la pista son más útiles para la línea de tiempo. navegación que las formas de onda. Sin embargo, generar miniaturas requiere más recursos más intensivo que generar formas de onda.

No podemos almacenar en caché todas las posibles miniaturas durante la carga, así que se puede decodificar rápidamente en la línea de tiempo. el desplazamiento lateral y el zoom son fundamentales para el rendimiento y la capacidad de respuesta de una aplicación. El para lograr un dibujo fluido de fotogramas es decodificar fotogramas, que hasta que hicimos recientemente con un reproductor de video HTML5. El rendimiento de ese enfoque no eran confiables y, a menudo, la capacidad de respuesta de la app se degradaba durante los fotogramas y procesamiento.

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

Un archivo de video contiene varias transmisiones: video, audio, subtítulos, etcétera. están “múltiples” entre sí. Para usar WebCodecs, primero necesitamos tener un video con demultiplexación. en tiempo real. Demux mp4s con la biblioteca mp4box, como se muestra aquí:

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 a MP4Box. Accedemos una vez más al recurso desde IndexedDB. Estos segmentos no se almacenan necesariamente en orden de bytes, y el appendBuffer devuelve el desplazamiento del siguiente bloque.

Así es como decodificamos 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 . Almacena cada fotograma en un array llamado samples. Usamos el demuxer para encontrar el fotograma clave anterior más cercano a nuestra marca de tiempo deseada, que es donde debemos comenzar la decodificación de video.

Los videos se componen de fotogramas completos, conocidos como fotogramas clave o I, así como fotogramas delta mucho más pequeños, a los que a menudo se hace referencia como fotogramas P o B. La decodificación siempre debe comiencen en un fotograma clave.

La aplicación decodifica marcos de la siguiente manera:

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

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

Próximos pasos

Definimos la escala en nuestro frontend como la capacidad de mantener un enfoque con un buen rendimiento a medida que los proyectos se hacen más grandes y complejos. Una forma de escalar es publicar la menor cantidad posible de videos a la vez, pero cuando lo hagamos esto, nos arriesgamos a que las transiciones sean lentas y entrecortadas. Si bien hemos desarrollado para almacenar componentes de video en caché para reutilizarlos, existen limitaciones el control de acceso que pueden ofrecer las etiquetas de video HTML5.

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

También podemos hacer un mejor trabajo al transferir grandes cálculos del panel táctil para trabajadores web, y podemos usar la precarga de forma más inteligente archivos y los marcos pregenerados. Vemos grandes oportunidades para optimizar el rendimiento general de la aplicación y extender la funcionalidad con herramientas como WebGL

Nos gustaría seguir invirtiendo en TensorFlow.js, que usamos actualmente para la eliminación inteligente del fondo. Planeamos aprovechar TensorFlow.js para otras tareas sofisticadas como detección de objetos, extracción de atributos, transferencia de estilo, etc.

Por último, nos emociona seguir desarrollando nuestro producto del rendimiento y la funcionalidad en una Web gratuita y abierta.