Kapwing: мощное редактирование видео для Интернета

Создатели теперь могут редактировать высококачественный видеоконтент в Интернете с помощью Kapwing благодаря мощным API (таким как IndexedDB и WebCodecs) и инструментам повышения производительности.

Джошуа Гроссберг
Joshua Grossberg

Потребление онлайн-видео быстро выросло с начала пандемии. Люди тратят больше времени на просмотр бесконечного высококачественного видео на таких платформах, как TikTok, Instagram и YouTube. Креативщикам и владельцам малого бизнеса во всем мире нужны быстрые и простые в использовании инструменты для создания видеоконтента.

Такие компании, как Kapwing, позволяют создавать весь этот видеоконтент прямо в Интернете, используя новейшие мощные API и инструменты повышения производительности.

О Капвинге

Kapwing — это веб-редактор для совместной работы, предназначенный в основном для обычных творческих людей, таких как стримеры игр, музыканты, создатели YouTube и мемеры. Это также полезный ресурс для владельцев бизнеса, которым нужен простой способ создания собственного социального контента, такого как реклама в Facebook и Instagram.

Люди находят Kapwing, выполняя поиск по конкретной задаче, например «как обрезать видео», «добавить музыку в мое видео» или «изменить размер видео». Они могут сделать то, что искали, всего одним щелчком мыши — без дополнительных проблем, связанных с переходом в магазин приложений и загрузкой приложения. Интернет позволяет людям легко найти именно ту задачу, с которой им нужна помощь, а затем выполнить ее.

После этого первого клика пользователи Kapwing смогут сделать гораздо больше. Они могут изучать бесплатные шаблоны, добавлять новые слои бесплатных стоковых видео, вставлять субтитры, расшифровывать видео и загружать фоновую музыку.

Как Kapwing обеспечивает редактирование и совместную работу в Интернете в режиме реального времени

Хотя Интернет предоставляет уникальные преимущества, он также создает определенные проблемы. Kapwing необходимо обеспечить плавное и точное воспроизведение сложных, многоуровневых проектов на широком спектре устройств и в сетевых условиях. Для достижения этой цели мы используем различные веб-API для достижения наших целей по производительности и функциональности.

ИндекседБД

Высокопроизводительное редактирование требует, чтобы весь контент наших пользователей находился на клиенте, по возможности избегая сети. В отличие от службы потоковой передачи, где пользователи обычно получают доступ к фрагменту контента один раз, наши клиенты повторно используют свои ресурсы часто, через несколько дней и даже месяцев после загрузки.

IndexedDB позволяет нам предоставлять нашим пользователям постоянное хранилище, подобное файловой системе. В результате более 90% медиазапросов в приложении выполняются локально. Интеграция IndexedDB в нашу систему прошла очень просто.

Вот некоторый шаблонный код инициализации, который запускается при загрузке приложения:

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

Мы передаем версию и определяем функцию upgrade . Это используется для инициализации или для обновления нашей схемы, когда это необходимо. Мы передаем обратные вызовы для обработки ошибок, blocked и blocking , которые, по нашему мнению, полезны для предотвращения проблем у пользователей с нестабильными системами.

Наконец, обратите внимание на наше определение первичного ключа keyPath . В нашем случае это уникальный идентификатор, который мы называем mediaLibraryID . Когда пользователь добавляет фрагмент медиафайла в нашу систему, будь то через наш загрузчик или стороннее расширение, мы добавляем медиафайл в нашу медиатеку с помощью следующего кода:

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 — это наша собственная внутренняя функция, которая сериализует доступ к IndexedDB. Это необходимо для любых операций типа чтения-изменения-записи, поскольку API IndexedDB является асинхронным.

Теперь давайте посмотрим, как мы получаем доступ к файлам. Ниже приведена наша функция 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;
}

У нас есть собственная структура данных idbCache , которая используется для минимизации доступа к IndexedDB. Хотя IndexedDB работает быстро, доступ к локальной памяти происходит быстрее. Мы рекомендуем этот подход, если вы управляете размером кэша.

Массив subscribers , который используется для предотвращения одновременного доступа к IndexedDB, в противном случае был бы общим при загрузке.

API веб-аудио

Аудиовизуализация невероятно важна для редактирования видео. Чтобы понять почему, взгляните на скриншот из редактора:

В редакторе Kapwing есть меню для мультимедиа, включающее несколько шаблонов и пользовательских элементов, включая некоторые шаблоны, специфичные для определенных платформ, таких как LinkedIn; временная шкала, разделяющая видео, аудио и анимацию; редактор холста с опциями качества экспорта; предварительный просмотр видео; и больше возможностей.

Это видео в стиле YouTube, которое часто встречается в нашем приложении. Пользователь не слишком много перемещается по клипу, поэтому визуальные миниатюры временной шкалы не так полезны для навигации между разделами. С другой стороны, форма звукового сигнала показывает пики и спады, причем спады обычно соответствуют мертвому времени записи. Если вы увеличите масштаб временной шкалы, вы увидите более детальную аудиоинформацию с провалами, соответствующими заиканиям и паузам.

Наши исследования пользователей показывают, что создатели часто руководствуются этими сигналами при объединении своего контента. API веб-аудио позволяет нам эффективно представлять эту информацию и быстро обновлять ее при масштабировании или панорамировании временной шкалы.

Фрагмент ниже демонстрирует, как мы это делаем:

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

Мы передаем этому помощнику актив, хранящийся в IndexedDB. По завершении мы обновим ресурс в IndexedDB, а также в нашем собственном кеше.

Мы собираем данные об audioBuffer с помощью конструктора AudioContext , но поскольку мы не выполняем рендеринг на аппаратное обеспечение устройства, мы используем OfflineAudioContext для рендеринга в ArrayBuffer , где мы будем хранить данные об амплитуде.

Сам API возвращает данные с частотой дискретизации, намного превышающей необходимую для эффективной визуализации. Вот почему мы вручную понижаем частоту до 200 Гц, чего, как мы обнаружили, достаточно для получения полезных и визуально привлекательных сигналов.

Вебкодеки

Для некоторых видео миниатюры дорожек более полезны для навигации по временной шкале, чем формы сигналов. Однако создание миниатюр требует больше ресурсов, чем создание сигналов.

Мы не можем кэшировать каждую потенциальную миниатюру при загрузке, поэтому быстрое декодирование при панорамировании/масштабировании временной шкалы имеет решающее значение для производительности и отзывчивости приложения. Узким местом в достижении плавной отрисовки кадров является декодирование кадров, которое до недавнего времени мы делали с помощью видеоплеера HTML5. Производительность этого подхода не была надежной, и мы часто наблюдали снижение скорости отклика приложений во время рендеринга кадров.

Недавно мы перешли на WebCodecs , который можно использовать в веб-воркерах. Это должно улучшить нашу способность рисовать миниатюры для большого количества слоев, не влияя на производительность основного потока. Хотя реализация веб-воркера все еще находится в стадии разработки, ниже мы приводим схему существующей реализации основного потока.

Видеофайл содержит несколько потоков: видео, аудио, субтитры и т. д., которые «мультиплексированы» вместе. Чтобы использовать WebCodecs, нам сначала нужно иметь демультиплексированный видеопоток. Мы демультифицируем mp4 с помощью библиотеки mp4box, как показано здесь:

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

Этот фрагмент относится к классу demuxer , который мы используем для инкапсуляции интерфейса MP4Box . Мы снова получаем доступ к активу из IndexedDB. Эти сегменты не обязательно хранятся в порядке байтов, и метод appendBuffer возвращает смещение следующего фрагмента.

Вот как мы декодируем видеокадр:

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

Структура демультиплексора довольно сложна и выходит за рамки этой статьи. Он сохраняет каждый кадр в массиве с названием samples . Мы используем демультиплексор, чтобы найти ближайший предыдущий ключевой кадр к желаемой временной метке, с которой мы должны начать декодирование видео.

Видео состоит из полных кадров, известных как ключевые или i-кадры, а также из гораздо меньших дельта-кадров, часто называемых p- или b-кадры. Декодирование всегда должно начинаться с ключевого кадра.

Приложение декодирует кадры:

  1. Создание экземпляра декодера с обратным вызовом вывода кадра.
  2. Настройка декодера для конкретного кодека и входного разрешения.
  3. Создание encodedVideoChunk с использованием данных демультиплексора.
  4. Вызов метода decodeEncodedFrame .

Делаем это до тех пор, пока не дойдем до кадра с нужной меткой времени.

Что дальше?

Мы определяем масштаб нашего интерфейса как способность поддерживать точное и производительное воспроизведение по мере того, как проекты становятся больше и сложнее. Один из способов масштабирования производительности — одновременное монтирование как можно меньшего количества видео, однако при этом мы рискуем получить медленные и прерывистые переходы. Хотя мы разработали внутренние системы для кэширования видеокомпонентов для повторного использования, существуют ограничения на степень контроля, которую могут обеспечить видеотеги HTML5.

В будущем мы можем попытаться воспроизвести все медиафайлы с помощью WebCodecs. Это может позволить нам очень точно определять, какие данные мы буферизуем, что поможет масштабировать производительность.

Мы также можем лучше перекладывать большие вычисления на трекпаде на веб-работников , и мы можем более разумно подходить к предварительной выборке файлов и предварительной генерации кадров. Мы видим большие возможности для оптимизации общей производительности наших приложений и расширения функциональности с помощью таких инструментов, как WebGL .

Мы хотели бы продолжить инвестиции в TensorFlow.js , который в настоящее время используем для интеллектуального удаления фона. Мы планируем использовать TensorFlow.js для других сложных задач, таких как обнаружение объектов, извлечение функций, передача стилей и т. д.

В конечном счете, мы рады продолжить создание нашего продукта с производительностью и функциональностью, сравнимыми с нативными, в бесплатном и открытом Интернете.