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. Pengguna menghabiskan lebih banyak waktu untuk menonton video berkualitas tinggi yang tidak 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. Aplikasi ini juga merupakan referensi andalan bagi pemilik bisnis yang membutuhkan cara mudah untuk memproduksi konten 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 saat membuka app store dan mendownload aplikasi. Web memudahkan pengguna 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, dengan pengguna biasanya mengakses konten satu kali, pelanggan kami sering menggunakan aset mereka, beberapa hari, bahkan beberapa bulan setelah upload.

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 sangatlah 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. Kita meneruskan callback penanganan error, blocked dan blocking, yang menurut kami berguna dalam mencegah masalah bagi pengguna dengan sistem yang tidak stabil.

Terakhir, perhatikan definisi kami tentang kunci utama keyPath. Dalam kasus ini, ini adalah ID unik yang kita sebut mediaLibraryID. Saat pengguna menambahkan media ke sistem, baik melalui uploader kami atau ekstensi pihak ketiga, kami akan menambahkan media tersebut ke koleksi 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 bagaimana kita 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 simultan ke IndexedDB, akan umum terjadi pada pemuatan.

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 mendetail dengan lembah yang terkait dengan ketersendatan dan jeda.

Riset pengguna kami menunjukkan bahwa kreator sering kali dipandu oleh bentuk gelombang ini saat menggabungkan konten mereka. 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;
    } 
  );

Kami meneruskan aset yang disimpan di tensorflow kepada 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 dengan 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, membuat thumbnail memerlukan banyak resource daripada membuat bentuk gelombang.

Kami tidak dapat meng-cache setiap thumbnail potensial saat dimuat, sehingga dekode 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 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, pertama-tama kita harus memiliki streaming video yang didemux. 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 mengacu pada class demuxer, yang kita gunakan untuk mengenkapsulasi antarmuka ke MP4Box. Kami kembali mengakses aset dari IndexedDB. Segmen ini tidak harus disimpan dalam urutan byte, dan metode appendBuffer menampilkan offset bagian berikutnya.

Berikut ini cara kami 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. Dekode harus selalu dimulai dari {i>key frame<i}.

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 meningkatkan performa adalah dengan memasang video sesedikit mungkin sekaligus, tetapi saat kita melakukannya, kita berisiko mengalami transisi yang lambat dan putus-putus. 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.

Kami juga dapat melakukan tugas yang lebih baik dalam mengurangi beban komputasi trackpad besar ke pekerja web, dan kami dapat lebih cerdas dalam melakukan pra-pengambilan file dan pra-pembuatan frame. Kami melihat peluang besar untuk mengoptimalkan performa aplikasi secara keseluruhan dan untuk memperluas fungsionalitas 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 membangun produk kami dengan performa dan fungsionalitas mirip native di web yang bebas dan terbuka.