Kapwing: zaawansowana edycja filmów w internecie

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

Joshua Grossberg
Joshua Grossberg

Oglądalność filmów w internecie gwałtownie wzrosła od początku pandemii. Użytkownicy spędzają więcej czasu na oglądaniu niekończących się filmów w wysokiej jakości na platformach takich jak TikTok, Instagram czy YouTube. Twórcy i właściciele małych firm na całym świecie potrzebują szybkich i łatwych w użyciu narzędzi do tworzenia treści wideo.

Firmy takie jak Kapwing umożliwiają tworzenie wszystkich tych treści wideo bezpośrednio w internecie, korzystając z najnowszych interfejsów API i narzędzi do zwiększania skuteczności.

Informacje o Kapwing

Kapwing to internetowy edytor wideo stworzony z myślą o osobistych twórcach takich jak transmitujący gry, muzycy, twórcy YouTube czy memy. Jest to również przydatne źródło informacji dla właścicieli firm, którzy potrzebują łatwego sposobu na tworzenie własnych treści społecznościowych, takich jak reklamy na Facebooku czy Instagramie.

Użytkownicy odkrywają Kapwinga, wyszukując konkretne zadanie, na przykład „jak przyciąć film”, „dodać muzykę do filmu” czy „zmienić rozmiar filmu”. Mogą wykonać to, czego szukali, jednym kliknięciem – bez konieczności przechodzenia do sklepu z aplikacjami i pobierania aplikacji. Internet ułatwia wyszukiwanie informacji o tym, jak wykonać konkretne zadanie, a potem pozwala je wykonać.

Po pierwszym kliknięciu użytkownicy Kapwing mają znacznie więcej możliwości. Mogą oni korzystać z bezpłatnych szablonów, dodawać nowe warstwy bezpłatnych filmów stockowych, wstawiać napisy, transkrybować filmy i przesyłać muzykę do odtwarzania w tle.

Jak Kapwing umożliwia edycję i współpracę w czasie rzeczywistym w internecie

Internet ma wiele zalet, ale wiąże się też z wyzwaniami. Kapwing musi zapewniać płynne i dokładne odtwarzanie złożonych projektów wielowarstwowych na wielu urządzeniach i w różnych warunkach sieciowych. Aby to osiągnąć, używamy różnych interfejsów API do przeglądarki, które pomagają nam w osiąganiu założonych celów dotyczących wydajności i funkcji.

IndexedDB

Edytowanie z wysoką wydajnością wymaga, aby wszystkie treści użytkowników były dostępne na kliencie, a nie w sieci, o ile to możliwe. W przeciwieństwie do serwisów streamingowych, w których użytkownicy zwykle korzystają z treści raz, nasi klienci często korzystają z zasobów wielokrotnie przez dni, a nawet miesiące po przesłaniu.

IndexedDB umożliwia nam udostępnianie użytkownikom trwałego miejsca na dane w ramach systemu plików. W efekcie ponad 90% żądań mediów w aplikacji jest realizowanych lokalnie. Integracja IndexedDB z naszym systemem była bardzo prosta.

Oto kod inicjowania kotła, 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 on do inicjalizacji lub aktualizacji schematu w razie potrzeby. Przekazujemy wywołania zwrotne obsługi błędów blocked i blocking, które okazały się przydatne podczas zapobiegania problemom w przypadku użytkowników niestabilnych systemów.

Na koniec zapoznaj się z definicją klucza podstawowego keyPath. W naszym przypadku jest to unikalny identyfikator mediaLibraryID. Gdy użytkownik dodaje do naszego systemu materiał multimedialny (za pomocą naszego narzędzia do przesyłania lub rozszerzenia innej firmy), dodajemy go do naszej 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 nasza własna funkcja zdefiniowana wewnętrznie, która serializuje dostęp do IndexedDB. Jest to wymagane w przypadku wszystkich operacji typu odczyt-modyfikacja-zapis, ponieważ interfejs IndexedDB API jest asynchroniczny.

Teraz przyjrzyjmy się temu, jak uzyskujemy dostęp do plików. Poniżej znajduje się 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ępów do IndexedDB. Technologia IndexedDB jest szybka, ale dostęp do pamięci lokalnej jest szybszy. Zalecamy to podejście, o ile zarządzasz rozmiarem pamięci podręcznej.

Tablica subscribers, która służy do zapobiegania jednoczesnemu dostępowi do IndexedDB, w przeciwnym razie byłaby częsta podczas wczytywania.

Interfejs API Web Audio

Wizualizacja dźwięku jest niezwykle ważna w przypadku edycji filmów. Aby to zrozumieć, spójrz na zrzut ekranu z edytowanego dokumentu:

Edytor Kapwing zawiera menu multimediów, w tym kilka szablonów i elementów niestandardowych, w tym niektóre szablony przeznaczone do określonych platform, np. LinkedIn. Znajdziesz tam też ścieżkę czasową, która oddziela film, dźwięk i animację, edytor kanwy z opcjami eksportowania, podgląd filmu i więcej funkcji.

To film w stylu YouTube, który jest powszechny w naszej aplikacji. Użytkownik nie porusza się zbytnio w trakcie klipu, więc wizualne miniatury na osi czasu nie są tak przydatne do poruszania się między sekcjami. Z drugiej strony przebieg fali dźwięku zawiera szczyty i doliny, przy czym doliny zwykle odpowiadają czasowi bez dźwięku w nagraniu. Jeśli przybliżysz osi czasu, zobaczysz bardziej szczegółowe informacje o dźwięku z dodatkowymi dołkami odpowiadającymi za zakłócenia i przerwy.

Nasze badania opinii użytkowników pokazują, że twórcy często kierują się tymi formami fali podczas łączenia treści. Interfejs API do dźwięku w internecie umożliwia nam prezentowanie tych informacji w sposób efektywny i szybkie aktualizowanie ich po powiększeniu lub przesunięciu osi czasu.

Poniżej fragment kodu pokazuje, jak to zrobić:

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

Przekazujemy temu pomocnikowi zasób przechowywany w IndexedDB. Po zakończeniu procesu zaktualizujemy zasób w IndexedDB oraz w naszej własnej pamięci podręcznej.

Dane o audioBuffer zbieramy za pomocą konstruktora AudioContext, ale ponieważ nie renderujemy na sprzęcie urządzenia, używamy funkcji OfflineAudioContext do renderowania na potrzeby ArrayBuffer, w którym będziemy przechowywać dane z Amplitude.

Sam interfejs API zwraca dane z częstotliwością próbkowania znacznie większą, niż jest to potrzebne do skutecznej wizualizacji. Dlatego ręcznie zmniejszamy częstotliwość do 200 Hz, co naszym zdaniem wystarcza do uzyskania przydatnych, atrakcyjnych wizualnie fal.

WebCodecs

W przypadku niektórych filmów miniatury ścieżek są przydatniejsze do nawigacji po osi czasu niż kształty fal. Generowanie miniatur wymaga jednak więcej zasobów niż generowanie przebiegów.

Nie możemy zapisać w pamięci podręcznej wszystkich możliwych miniatur podczas wczytywania, dlatego szybkie dekodowanie na osi czasu podczas przesuwania i powiększania jest kluczowe dla wydajności i szybkości działania aplikacji. Wąską gardłem płynnego rysowania klatek jest dekodowanie ramek, których jeszcze nie używaliśmy w odtwarzaczu HTML5. Takie podejście nie było niezawodne i często powodowało spowolnienie działania aplikacji podczas renderowania klatek.

Ostatnio przeszliśmy na kode WebCodecs, których można używać w instancjach roboczych. Powinno to zwiększyć możliwości rysowania miniatur w przypadku dużej liczby warstw bez wpływu na wydajność wątku. Implementacja web worker jest nadal w toku, ale poniżej przedstawiamy zarys obecnej implementacji głównego wątku.

Plik wideo zawiera wiele strumieni: wideo, audio, napisy itp., które są „zmuxowane”. Aby używać WebCodecs, najpierw musimy mieć demuxowany strumień wideo. Demuxowanie plików mp4 odbywa się za pomocą biblioteki mp4box, jak pokazano tutaj:

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óra służy do zamykania interfejsu MP4Box. Ponownie uzyskujemy dostęp do zasobu z IndexedDB. Takie 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 demuxera jest dość złożona i wykracza poza zakres tego artykułu. Każda klatka jest zapisywana w tablicy samples. Korzystamy z demuksera, aby znaleźć najbliższą klatkę kluczową do żądanej sygnatury czasowej, od której trzeba rozpocząć dekodowanie filmu.

Filmy składają się z pełnych klatek, zwanych klatkami kluczowymi lub klatkami I, a także znacznie mniejszych klatek delta, często określanych jako klatki P lub B. Dekodowanie musi zawsze rozpoczynać się od klatki kluczowej.

Aplikacja dekoduje klatki, wykonując te czynności:

  1. Tworzenie instancji dekodera z wywołaniem zwrotnym wyjścia ramki.
  2. Konfigurowanie dekodera pod kątem konkretnego kodeka i rozdzielczości wejściowej.
  3. Tworzę encodedVideoChunk z użyciem danych z demuksera.
  4. wywołanie metody decodeEncodedFrame.

Robimy to, aż dotrzemy do ramki z odpowiednią sygnaturą czasową.

Co dalej?

Skalowanie na stronie frontendu definiujemy jako zdolność do utrzymywania precyzyjnego i wydajnego odtwarzania w miarę zwiększania rozmiaru i złożoności projektów. Jednym ze sposobów zwiększenia wydajności jest jednoczesne zamontowanie jak najmniejszej liczby filmów, ale w ten sposób narażamy się na ryzyko spowolnienia i niepłynności przejść. Opracowane przez nas wewnętrzne systemy przechowują w pamięci podręcznej komponenty wideo na potrzeby ponownego użycia, ale możliwości sterowania tagami wideo HTML5 są ograniczone.

W przyszłości możemy próbować odtwarzać wszystkie multimedia przy użyciu kodeków internetowych. Dzięki temu możemy dokładnie określić, jakie dane buforujemy, co powinno pomóc w zwiększeniu wydajności.

Możemy też lepiej odciążyć procesy obliczeniowe na trackpadzie, a także inteligentniej pobierać wstępnie pliki i generować ramki. Widzimy duże możliwości optymalizacji ogólnej wydajności aplikacji i rozszerzenia funkcjonalności za pomocą narzędzi takich jak WebGL.

Chcemy kontynuować inwestycje w TensorFlow.js, którego obecnie używamy do inteligentnego usuwania tła. Planujemy wykorzystać TensorFlow.js do innych zaawansowanych zadań, takich jak wykrywanie obiektów, wyodrębnianie cech, przenoszenie stylu itp.

Ostatecznie cieszymy się, że możemy dalej rozwijać nasz produkt, który zapewnia wydajność i funkcjonalność na poziomie aplikacji natywnych w bezpłatnej i otwartej sieci.