Kapwing: zaawansowana edycja filmów w internecie

Twórcy mogą teraz edytować wysokiej jakości treści wideo w internecie za pomocą platformy Kapwing dzięki zaawansowanym interfejsom API (takim jak IndexedDB i WebCodecs) oraz narzędziom do zarządzania wydajnością.

Joshua Grossberg
Joshua Grossberg

Oglądalność filmów online gwałtownie wzrosła od początku pandemii. Użytkownicy spędzają coraz więcej czasu, oglądając niekończące się filmy o wysokiej jakości na platformach takich jak TikTok, Instagram i YouTube. Kreacje i właściciele małych firm na całym świecie potrzebują szybkich i łatwych w obsłudze narzędzi do tworzenia treści wideo.

Firmy takie jak Kapwing umożliwiają tworzenie wszystkich materiałów wideo bezpośrednio w internecie za pomocą najnowszych interfejsów API i narzędzi poprawiających wydajność.

Kapwing – informacje

Kapwing to internetowy edytor wideo przeznaczony przede wszystkim dla twórców okazjonalnych, takich jak twórcy gier, muzycy, twórcy YouTube i twórcy memów. Jest to też przydatne źródło informacji dla właścicieli firm, którzy potrzebują łatwego sposobu tworzenia własnych treści w mediach społecznościowych, takich jak reklamy na Facebooku czy Instagramie.

Użytkownicy odkrywają Kapwing, wyszukując konkretne zadania, na przykład „jak przyciąć film”, „dodać muzykę do filmu” czy „zmienić rozmiar filmu”. Mogą zrobić to, czego szukali, za pomocą jednego kliknięcia – bez konieczności chodzenia do sklepu z aplikacjami i pobierania aplikacji. Internet ułatwia użytkownikom znalezienie i wykonanie dokładnie tego zadania, którego potrzebują.

Po tym pierwszym kliknięciu użytkownicy Kapwing mogą robić znacznie więcej. Mogą przeglądać bezpłatne szablony, dodawać nowe warstwy bezpłatnych filmów z banku wideo, wstawiać napisy, tworzyć transkrypcje filmów i przesyłać podkład muzyczny w tle.

Jak Kapwing przenosi edytowanie i współpracę w czasie rzeczywistym w internecie

Internet ma swoje wyjątkowe zalety, ale wiążą się z nim również różne wyzwania. Kapwing musi zapewnić płynne i precyzyjne odtwarzanie złożonych, wielowarstwowych projektów z uwzględnieniem wielu różnych urządzeń i warunków sieci. Aby to osiągnąć, korzystamy z różnych internetowych interfejsów API, które pozwalają nam osiągnąć cele związane z wydajnością i funkcjami.

IndexedDB

Efektywne edytowanie wymaga, aby cała treść naszych użytkowników znajdowała się w kliencie, unikając połączenia z siecią, gdy to możliwe. W odróżnieniu od usługi strumieniowania, w przypadku której użytkownicy zwykle uzyskują dostęp do treści tylko raz, nasi klienci często używają ich zasobów, a także dni, a nawet miesiące po ich przesłaniu.

Technologia IndexedDB pozwala nam udostępniać użytkownikom trwałą pamięć masową (podobną do systemu plików). W efekcie ponad 90% żądań multimediów w aplikacji jest realizowanych lokalnie. Integracja IndexedDB z naszym systemem była bardzo prosta.

Oto kod inicjowania płyty głównej, który uruchamia się podczas wczytywania aplikacji:

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

Przekazujemy wersję i definiujemy funkcję upgrade. Służy do inicjowania lub aktualizowania schematu, gdy to konieczne. Przekazujemy z obsługą błędów wywołania zwrotne blocked i blocking, które okazały się przydatne podczas zapobiegania problemom w przypadku użytkowników z niestabilnym systemem.

Zwróć uwagę na naszą definicję klucza podstawowego keyPath. W naszym przypadku jest to unikalny identyfikator, który nazywamy mediaLibraryID. Gdy użytkownik dodaje multimedia do naszego systemu (za pomocą programu do przesyłania lub rozszerzenia innej firmy), dodajemy je do biblioteki multimediów za pomocą tego kodu:

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 to własna, wewnętrznie zdefiniowana funkcja, która serializuje dostęp do IndexedDB. Jest to wymagane w przypadku wszystkich operacji odczytu, modyfikacji i zapisu, ponieważ interfejs IndexedDB API jest asynchroniczny.

Przyjrzyjmy się teraz, jak uzyskujemy dostęp do plików. Poniżej znajduje się nasza funkcja 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;
}

Mamy własną strukturę danych (idbCache), która służy do minimalizowania dostępu do IndexedDB. Mimo że IndexedDB działa szybko, dostęp do pamięci lokalnej jest szybszy. Zalecamy tę metodę, jeśli zarządzasz rozmiarem pamięci podręcznej.

Tablica subscribers, która służy do zapobiegania jednoczesnemu dostępowi do IndexedDB, byłaby w innym miejscu wspólna podczas wczytywania.

Interfejs API Web Audio

Wizualizacja dźwięku jest niezwykle ważna w procesie edycji wideo. Aby dowiedzieć się, dlaczego tak jest, spójrz na zrzut ekranu z edytora:

Edytor Kapwing ma menu dla multimediów, w tym kilka szablonów i elementów niestandardowych, w tym szablony dostępne tylko na niektórych platformach, takich jak LinkedIn, oś czasu rozdzielającą wideo, dźwięk i animację, edytor canvas z opcjami jakości eksportu, podgląd filmu i inne możliwości.

To film w stylu YouTube, co jest często spotykane w naszej aplikacji. Użytkownik nie porusza się zbyt często w trakcie klipu, więc wizualne miniatury na osi czasu nie są przydatne podczas przechodzenia między sekcjami. Z drugiej strony fala audio pokazuje szczyty i doliny, przy czym te doliny zazwyczaj odpowiadają czasowi przerwy w nagraniu. Jeśli powiększysz oś czasu, zobaczysz bardziej szczegółowe informacje o dźwięku z dolinami odpowiadającymi zacinaniu się i przerwom.

Nasze badania opinii użytkowników pokazują, że twórcy często kierują się tymi falami, gdy dzielą swoje treści. Interfejs Web Audio API pozwala nam prezentować te informacje w wydajny sposób i szybko aktualizować się wraz z powiększeniem lub przesunięciem osi czasu.

Poniższy fragment kodu pokazuje, jak to robimy:

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

Przekażemy temu asystentowi zasób przechowywany w IndexedDB. Gdy skończysz, zaktualizujemy zasób w IndexedDB, a także w naszej pamięci podręcznej.

Dane o elemencie audioBuffer zbieramy za pomocą konstruktora AudioContext, ale ponieważ nie renderujemy danych na sprzęcie urządzenia, używamy OfflineAudioContext do renderowania w elemencie ArrayBuffer, w którym przechowujemy dane amplitudy.

Sam interfejs API zwraca dane z częstotliwością próbkowania znacznie większą niż jest to wymagane do efektywnej wizualizacji. Dlatego ręcznie zmniejszamy częstotliwość do 200 Hz, która wydaje się wystarczająca do tworzenia przydatnych, atrakcyjnych wizualnie fal.

WebCodecs

W przypadku niektórych filmów miniatury ścieżek przydają się do nawigacji na osi czasu niż do kształtów fali. Generowanie miniatur pochłania jednak więcej zasobów niż generowanie fali.

Nie możemy zapisać każdej potencjalnej miniatury w pamięci podręcznej podczas wczytywania, dlatego szybkie dekodowanie przesuwania/powiększania osi czasu ma kluczowe znaczenie dla wydajnej i responsywnej aplikacji. Kluczem do płynnego rysowania klatek jest dekodowanie klatek. Do niedawna używaliśmy odtwarzacza wideo HTML5. Wydajność tego podejścia była niestabilna i często zauważyliśmy pogorszenie reagowania aplikacji podczas renderowania klatek.

Niedawno przeszliśmy na kod WebCodecs, którego można używać w webCodecs. Powinno to zwiększyć nasze możliwości tworzenia miniatur dla dużej liczby warstw bez wpływu na wydajność wątku głównego. Chociaż wciąż trwa wdrażanie rozwiązań dla robotów, poniżej podajemy przegląd naszej obecnej implementacji głównego wątku.

Plik wideo zawiera wiele strumieni: wideo, audio, napisy itd., które są ze sobą „miksowane”. Aby używać WebCodecs, musimy najpierw uzyskać zdekompresowany strumień wideo. Pliki MP4 przedstawiamy w bibliotece mp4box w ten sposób:

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

Ten fragment kodu odnosi się do klasy demuxer, której używamy do opisania interfejsu w polu MP4Box. Ponownie uzyskujemy dostęp do zasobu z IndexedDB. Te segmenty nie muszą być przechowywane w kolejności bajtów, a metoda appendBuffer zwraca przesunięcie następnego fragmentu.

Oto jak dekodujemy klatkę wideo:

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

Struktura demultipleksera jest dość złożona i wykracza poza zakres tego artykułu. Każda klatka jest przechowywana w tablicy samples. Używamy demuksera, aby znaleźć najbliższą poprzedzającą klatkę kluczową najbliższą sygnaturę czasową, od której musimy rozpocząć dekodowanie filmu.

Filmy składają się z pełnych klatek (kluczy lub i-Frame), a także z mniejszych klatek delta, często nazywanych klatkami „p” lub „b”. Dekodowanie musi zawsze zaczynać się od klatki kluczowej.

Aplikacja dekoduje ramki przez:

  1. Tworzę instancję dekodera z wywołaniem zwrotnym wyjściowym ramki.
  2. Konfigurowanie dekodera pod kątem określonego kodeka i rozdzielczości wejściowej.
  3. Tworzę encodedVideoChunk z użyciem danych z demuksera.
  4. Wywoływanie metody decodeEncodedFrame.

Robimy to, dopóki nie pojawi się ramka z żądaną sygnaturą czasową.

Co dalej?

W naszym frontendzie skalowanie definiujemy jako możliwość utrzymania dokładnego i wydajnego odtwarzania w miarę, jak projekty stają się większe i bardziej złożone. Jednym ze sposobów skalowania wydajności jest dodanie jak najmniejszej liczby filmów naraz, ale wiąże się to z ryzykiem powolnego i zacinania się przejść. Opracowaliśmy systemy wewnętrzne do buforowania komponentów wideo w celu ponownego wykorzystania, ale istnieją pewne ograniczenia kontroli tagów wideo HTML5.

W przyszłości możemy spróbować odtwarzać wszystkie multimedia przy użyciu kodeków WebCodecs. Dzięki temu będziemy mogli bardzo precyzyjnie określić, jakie dane buforujemy, i w ten sposób skalować wydajność.

Możemy też sami lepiej przejmować duże obliczenia na trackpadzie pracownikami internetowymi oraz radzić sobie z pobieraniem z wyprzedzeniem plików i wstępnym generowaniem klatek. Widzimy duże możliwości optymalizacji ogólnej wydajności naszej aplikacji i rozszerzania funkcjonalności za pomocą narzędzi takich jak WebGL.

Chcemy nadal inwestować w rozwój TensorFlow.js, którego obecnie używamy do inteligentnego usuwania danych w tle. Planujemy wykorzystać TensorFlow.js w innych zaawansowanych zadaniach, takich jak wykrywanie obiektów, wyodrębnianie cech czy przenoszenie stylów.

Chcemy w dalszym ciągu tworzyć w bezpłatnej i otwartej sieci naszą usługę o wydajności i funkcjonalności na podobnej zasadzie co rozwiązania natywne.