Kapwing: Pengeditan video yang canggih untuk web

Kreator kini dapat mengedit konten video berkualitas tinggi di web dengan Kapwing, berkat API yang canggih (seperti IndexedDB dan WebCodecs) serta alat performa.

Joshua Grossberg
Joshua Grossberg

Konsumsi video online telah berkembang pesat sejak awal pandemi. Orang-orang menghabiskan lebih banyak waktu untuk menonton video berkualitas tinggi yang tak ada habisnya di platform seperti TikTok, Instagram, dan YouTube. Kreator dan pemilik bisnis kecil di seluruh dunia memerlukan alat yang cepat dan mudah digunakan untuk membuat konten video.

Perusahaan seperti Kapwing memungkinkan Anda membuat semua konten video ini langsung di web, dengan menggunakan API dan alat performa terbaru yang canggih.

Tentang Kapwing

Kapwing adalah editor video kolaboratif berbasis web yang dirancang terutama untuk kreator santai seperti streamer game, musisi, kreator YouTube, dan pembuat meme. Ini juga merupakan referensi utama bagi pemilik bisnis yang memerlukan cara mudah untuk membuat konten media sosial mereka sendiri, seperti iklan Facebook dan Instagram.

Orang menemukan Kapwing dengan menelusuri tugas tertentu, misalnya "cara memotong video", "menambahkan musik ke video saya", atau "mengubah ukuran video". Mereka dapat melakukan hal yang mereka telusuri hanya dengan sekali klik—tanpa hambatan tambahan untuk membuka app store dan mendownload aplikasi. Web memudahkan pengguna untuk menelusuri tugas yang memerlukan bantuan, lalu melakukannya.

Setelah klik pertama, pengguna Kapwing dapat melakukan banyak hal lainnya. Mereka dapat menjelajahi template gratis, menambahkan lapisan baru dari video stok gratis, menyisipkan subtitel, mentranskripsikan video, dan mengupload musik latar belakang.

Cara Kapwing menghadirkan pengeditan dan kolaborasi real-time ke web

Meskipun web memberikan keunggulan unik, web juga menghadirkan tantangan yang berbeda. Kapwing perlu memberikan pemutaran yang lancar dan presisi untuk project multilapis yang kompleks di berbagai perangkat dan kondisi jaringan. Untuk mencapai hal ini, kami menggunakan berbagai API web untuk mencapai sasaran performa dan fitur kami.

IndexedDB

Pengeditan berperforma tinggi mengharuskan semua konten pengguna kami ditayangkan di klien, sehingga menghindari jaringan jika memungkinkan. Tidak seperti layanan streaming, tempat pengguna biasanya mengakses konten sekali, pelanggan kami sering menggunakan kembali aset mereka, beberapa hari dan bahkan beberapa bulan setelah diupload.

IndexedDB memungkinkan kita menyediakan penyimpanan seperti sistem file persisten kepada pengguna. Hasilnya, lebih dari 90% permintaan media di aplikasi dipenuhi secara lokal. Mengintegrasikan IndexedDB ke dalam sistem kami sangat mudah.

Berikut adalah beberapa kode inisialisasi boilerplate yang berjalan saat aplikasi dimuat:

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

Kita meneruskan versi dan menentukan fungsi upgrade. Ini digunakan untuk inisialisasi atau untuk memperbarui skema jika diperlukan. Kami meneruskan callback penanganan error, blocked dan blocking, yang kami temukan berguna dalam mencegah masalah bagi pengguna dengan sistem yang tidak stabil.

Terakhir, perhatikan definisi kunci utama keyPath. Dalam kasus ini, ini adalah ID unik yang kita sebut mediaLibraryID. Saat pengguna menambahkan media ke sistem kami, baik melalui uploader kami maupun ekstensi pihak ketiga, kami akan menambahkan media tersebut ke library media kami dengan kode berikut:

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 adalah fungsi yang ditentukan secara internal yang melakukan serialisasi akses IndexedDB. Hal ini diperlukan untuk operasi jenis baca-ubah-tulis, karena IndexedDB API bersifat asinkron.

Sekarang, mari kita lihat cara mengakses file. Berikut adalah fungsi 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;
}

Kita memiliki struktur data kita sendiri, idbCache, yang digunakan untuk meminimalkan akses IndexedDB. Meskipun IndexedDB cepat, mengakses memori lokal lebih cepat. Sebaiknya gunakan pendekatan ini selama Anda mengelola ukuran cache.

Array subscribers, yang digunakan untuk mencegah akses serentak ke IndexedDB, akan umum saat dimuat.

Web Audio API

Visualisasi audio sangat penting untuk pengeditan video. Untuk memahami alasannya, lihat screenshot dari editor:

Editor Kapwing memiliki menu untuk media, termasuk beberapa template dan elemen kustom, termasuk beberapa template yang khusus untuk platform tertentu seperti LinkedIn; linimasa yang memisahkan video, audio, dan animasi; editor kanvas dengan opsi kualitas ekspor; pratinjau video; dan kemampuan lainnya.

Ini adalah video bergaya YouTube, yang umum di aplikasi kami. Pengguna tidak berpindah terlalu banyak di seluruh klip, sehingga thumbnail visual linimasa tidak begitu berguna untuk berpindah antar-bagian. Di sisi lain, waveform audio menunjukkan puncak dan lembah, dengan lembah biasanya sesuai dengan waktu mati dalam rekaman. Jika memperbesar linimasa, Anda akan melihat informasi audio yang lebih terperinci dengan lembah yang sesuai dengan gangguan dan jeda.

Riset pengguna kami menunjukkan bahwa kreator sering kali dipandu oleh bentuk gelombang ini saat mereka menyambungkan konten. Web audio API memungkinkan kita menampilkan informasi ini dengan performa yang baik dan memperbarui dengan cepat saat memperbesar atau menggeser linimasa.

Cuplikan di bawah menunjukkan cara melakukannya:

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

Kita meneruskan aset yang disimpan di IndexedDB ke helper ini. Setelah selesai, kita akan memperbarui aset di IndexedDB serta cache kita sendiri.

Kita mengumpulkan data tentang audioBuffer dengan konstruktor AudioContext, tetapi karena kita tidak merender ke hardware perangkat, kita menggunakan OfflineAudioContext untuk merender ke ArrayBuffer tempat kita akan menyimpan data amplitudo.

API itu sendiri menampilkan data pada frekuensi sampel yang jauh lebih tinggi daripada yang diperlukan untuk visualisasi yang efektif. Itulah sebabnya kami melakukan downsampling secara manual ke 200 Hz, yang kami temukan cukup untuk bentuk gelombang yang berguna dan menarik secara visual.

WebCodecs

Untuk video tertentu, thumbnail trek lebih berguna untuk navigasi linimasa daripada bentuk gelombang. Namun, pembuatan thumbnail lebih banyak memerlukan resource daripada pembuatan bentuk gelombang.

Kita tidak dapat meng-cache setiap thumbnail potensial saat pemuatan, sehingga decoding cepat pada geser/zoom linimasa sangat penting untuk aplikasi yang berperforma tinggi dan responsif. Bottleneck untuk mencapai gambar frame yang lancar adalah mendekode frame, yang hingga baru-baru ini kita lakukan menggunakan pemutar video HTML5. Performa pendekatan tersebut tidak dapat diandalkan dan kami sering melihat responsivitas aplikasi yang menurun selama rendering frame.

Baru-baru ini kami beralih ke WebCodecs, yang dapat digunakan di pekerja web. Hal ini akan meningkatkan kemampuan kita untuk menggambar thumbnail untuk lapisan dalam jumlah besar tanpa memengaruhi performa thread utama. Meskipun penerapan pekerja web masih dalam proses, kami memberikan ringkasan di bawah tentang penerapan thread utama yang ada.

File video berisi beberapa streaming: video, audio, subtitel, dan sebagainya yang 'di-mux' bersama. Untuk menggunakan WebCodecs, kita harus memiliki streaming video yang telah didemux terlebih dahulu. Kita melakukan demux mp4 dengan library mp4box, seperti yang ditunjukkan di sini:

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

Cuplikan ini merujuk ke class demuxer, yang kita gunakan untuk mengenkapsulasi antarmuka ke MP4Box. Kita kembali mengakses aset dari IndexedDB. Segmen ini tidak harus disimpan dalam urutan byte, dan metode appendBuffer menampilkan offset bagian berikutnya.

Berikut cara mendekode frame 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 };
};

Struktur demuxer cukup kompleks dan berada di luar cakupan artikel ini. Fungsi ini menyimpan setiap frame dalam array berjudul samples. Kita menggunakan demuxer untuk menemukan frame kunci sebelumnya yang paling dekat dengan stempel waktu yang diinginkan, yaitu tempat kita harus memulai dekode video.

Video terdiri dari frame penuh, yang dikenal sebagai frame kunci atau i-frame, serta frame delta yang jauh lebih kecil, yang sering disebut sebagai frame p- atau b-frame. Dekode harus selalu dimulai pada frame utama.

Aplikasi mendekode frame dengan:

  1. Membuat instance decoder dengan callback output frame.
  2. Mengonfigurasi dekoder untuk codec dan resolusi input tertentu.
  3. Membuat encodedVideoChunk menggunakan data dari demuxer.
  4. Memanggil metode decodeEncodedFrame.

Kita melakukannya hingga mencapai frame dengan stempel waktu yang diinginkan.

Apa langkah selanjutnya?

Kami mendefinisikan skala di frontend sebagai kemampuan untuk mempertahankan pemutaran yang presisi dan berperforma tinggi seiring bertambah besarnya project dan semakin kompleksnya project. Salah satu cara untuk menskalakan performa adalah dengan memasang video sesedikit mungkin sekaligus. Namun, saat melakukan hal ini, kita berisiko mengalami transisi yang lambat dan tidak lancar. Meskipun kami telah mengembangkan sistem internal untuk meng-cache komponen video agar dapat digunakan kembali, ada batasan jumlah kontrol yang dapat diberikan tag video HTML5.

Di masa mendatang, kami mungkin akan mencoba memutar semua media menggunakan WebCodecs. Hal ini dapat memungkinkan kita sangat akurat dalam menentukan data yang di-buffer yang akan membantu menskalakan performa.

Kita juga dapat melakukan tugas yang lebih baik dalam mentransfer komputasi trackpad besar ke pekerja web, dan kita dapat lebih cerdas dalam melakukan pra-pengambilan file dan pra-pembuatan frame. Kami melihat peluang besar untuk mengoptimalkan performa aplikasi secara keseluruhan dan memperluas fungsi dengan alat seperti WebGL.

Kami ingin melanjutkan investasi kami di TensorFlow.js, yang saat ini kami gunakan untuk penghapusan latar belakang cerdas. Kami berencana memanfaatkan TensorFlow.js untuk tugas rumit lainnya seperti deteksi objek, ekstraksi fitur, transfer gaya, dan sebagainya.

Pada akhirnya, kami senang dapat terus membuat produk dengan performa dan fungsi seperti native di web gratis dan terbuka.