Kapwing: Pengeditan video yang canggih untuk web

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

Joshua Grossberg
Joshua Grossberg

Konsumsi video online telah meningkat pesat sejak awal pandemi. Orang-orang menghabiskan lebih banyak waktu untuk menonton video berkualitas tinggi tanpa henti platform seperti TikTok, Instagram, dan YouTube. Materi iklan dan bisnis kecil pemilik di seluruh dunia membutuhkan alat yang cepat dan mudah digunakan untuk membuat saat ini.

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

Tentang Kapwing

Kapwing adalah editor video kolaboratif berbasis web yang dirancang terutama untuk konten kasual orang-orang kreatif seperti {i>game-streamer<i}, musisi, kreator YouTube, dan meme. Penting juga merupakan sumber daya utama bagi pemilik bisnis yang membutuhkan cara mudah untuk menghasilkan konten sosial mereka sendiri, seperti iklan Facebook dan Instagram.

Orang-orang menemukan Kapwing dengan menelusuri tugas tertentu, misalnya "cara memangkas video," "tambahkan musik ke video saya," atau "mengubah ukuran video." Mereka dapat melakukan apa mereka telusuri hanya dengan satu klik—tanpa gangguan tambahan menavigasi ke {i>app store<i} dan mengunduh aplikasi. Web memudahkan orang untuk mencari dengan tepat tugas apa yang mereka perlukan bantuannya, dan kemudian melakukannya.

Setelah klik pertama itu, pengguna Kapwing dapat melakukan lebih banyak hal lagi. Mereka dapat jelajahi template gratis, tambahkan lapisan baru video stok gratis, sisipkan subtitel, transkripsi video, dan upload musik latar belakang.

Cara Kapwing menghadirkan pengeditan dan kolaborasi real-time ke web

Meskipun web memberikan keuntungan yang unik, web ini juga menghadirkan perbedaan tantangan. Kapwing perlu menghadirkan pemutaran yang kompleks dan kompleks, proyek berlapis di berbagai perangkat dan kondisi jaringan. Untuk mencapai hal ini, kami menggunakan berbagai API web untuk mencapai performa dan sasaran fitur.

IndexedDB

Pengeditan berperforma tinggi mengharuskan semua konten tersedia di klien, menghindari jaringan bila memungkinkan. Tidak seperti layanan {i>streaming<i}, di mana pengguna biasanya mengakses sebuah konten satu kali, pelanggan kami menggunakan kembali aset mereka secara rutin, beberapa hari, bahkan berbulan-bulan setelah diupload.

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

Berikut adalah beberapa kode inisialisasi pelat pemanas air 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 atau untuk memperbarui skema bila diperlukan. Kita meneruskan penanganan error callback, blocked dan blocking, yang menurut kami berguna mencegah masalah bagi pengguna dengan sistem yang tidak stabil.

Terakhir, perhatikan definisi kami tentang kunci utama keyPath. Dalam kasus kita, ini adalah ID unik yang kita sebut mediaLibraryID. Saat pengguna menambahkan media ke sistem kami, baik melalui uploader kami atau ekstensi pihak ketiga, kami 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 kami yang melakukan serialisasi Akses IndexedDB. Ini diperlukan untuk setiap operasi jenis baca-ubah-tulis, karena tensorflow API bersifat asinkron.

Sekarang mari kita lihat bagaimana kita mengakses file. Di bawah ini adalah fungsi getAsset kita:

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

Kami memiliki struktur data kami sendiri, idbCache, yang digunakan untuk meminimalkan IndexedDB akses. Meskipun IndexedDB cepat, mengakses memori lokal lebih cepat. Rab menyarankan pendekatan ini selama Anda mengelola ukuran {i>cache<i}.

Array subscribers, yang digunakan untuk mencegah akses secara simultan ke AlarmManager, jika tidak akan umum saat dimuat.

API Audio Web

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

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

Ini adalah video bergaya YouTube, yang umum ditemukan di aplikasi kami. Pengguna tidak banyak bergerak di sepanjang klip, sehingga {i> thumbnail<i} visual garis waktu tidak berguna untuk berpindah antarbagian. Di sisi lain, bentuk gelombang menunjukkan puncak dan lembah, dengan lembah yang biasanya sesuai dengan waktu mati dalam rekaman. Jika Anda memperbesar garis waktu, Anda akan melihat informasi audio yang lebih halus dengan lembah yang sesuai dengan ketersendatan dan jeda.

Riset pengguna kami menunjukkan bahwa kreator sering kali dipandu oleh bentuk gelombang ini sebagai mereka membagi konten mereka. API audio web memungkinkan kita untuk menyajikan informasi dengan cepat dan untuk memperbarui dengan cepat pada perbesaran atau geser {i>timeline<i} (rentang waktu).

Cuplikan di bawah menunjukkan cara kami 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 IndexedDB. Setelah selesai, kami akan memperbarui aset di IndexedDB serta cache kita sendiri.

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

API itu sendiri menampilkan data dengan frekuensi sampel yang jauh lebih tinggi dari yang diperlukan visualisasi yang efektif. Itu sebabnya kita menurunkan sampel secara manual ke 200 Hz, yang didapati cukup untuk membuat bentuk gelombang yang berguna dan menarik secara visual.

WebCodecs

Untuk video tertentu, thumbnail lagu lebih berguna untuk rentang waktu navigasi dibandingkan bentuk gelombang. Namun, membuat thumbnail lebih merupakan intensif daripada menghasilkan bentuk gelombang.

Kami tidak dapat menyimpan setiap thumbnail potensial dalam cache saat dimuat, jadi dekode di linimasa dengan cepat fungsi geser/zoom sangat penting untuk aplikasi yang berperforma tinggi dan responsif. Tujuan bottleneck untuk mencapai gambar frame yang lancar adalah decoding frame, yang hingga baru-baru ini kita menggunakan pemutar video HTML5. Performa pendekatan tersebut tidak dapat diandalkan dan kami sering melihat penurunan respons aplikasi selama frame proses rendering.

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

File video berisi beberapa streaming: video, audio, subtitel, dan sebagainya di-'muxed' secara bersamaan. Untuk menggunakan WebCodecs, pertama-tama kita harus memiliki video yang didemux feed. Kami mendemux mp4 dengan perpustakaan 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. Ini segmen tidak harus disimpan dalam urutan byte, dan bahwa appendBuffer mengembalikan offset dari potongan 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. Fungsi ini menyimpan setiap frame dalam array bernama samples. Kita menggunakan demuxer untuk menemukan {i>frame key<i} sebelumnya yang paling dekat dengan stempel waktu yang kita inginkan, yaitu di mana kami harus memulai dekode video.

Video terdiri dari frame penuh, yang dikenal sebagai key atau i-frame, serta banyak {i>frame<i} delta yang lebih kecil, sering disebut sebagai {i>p-<i} atau {i>b-frame<i}. Dekode harus selalu dimulai dari {i>key frame<i}.

Aplikasi ini mendekode frame dengan:

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

Kita melakukannya sampai mencapai {i>frame<i} dengan stempel waktu yang diinginkan.

Apa langkah selanjutnya?

Kami mendefinisikan skala di frontend sebagai kemampuan untuk mempertahankan performa yang bagus saat project menjadi lebih besar dan kompleks. Salah satu cara untuk meningkatkan skala performa adalah menempatkan video sesedikit mungkin sekaligus. Namun saat kami ini, kita berisiko mengalami transisi yang lambat dan putus-putus. Sementara kami mengembangkan untuk meng-cache komponen video agar dapat digunakan kembali, ada batasan berapa banyak yang dapat disediakan tag video HTML5.

Di masa mendatang, kami mungkin akan mencoba memutar semua media menggunakan WebCodecs. Hal ini dapat memungkinkan kita untuk menentukan dengan tepat data apa yang kita {i>buffer<i} sehingga dapat membantu menskalakan tingkat tinggi.

Kita juga dapat melakukan pekerjaan yang lebih baik dari beban komputasi {i>trackpad<i} yang besar untuk web worker, dan kita dapat lebih cerdas dalam melakukan pre-fetching dan pra-pembuatan {i>frame<i}. Kami melihat peluang besar untuk mengoptimalkan performa aplikasi secara keseluruhan dan untuk memperluas fungsionalitas dengan alat seperti WebGL.

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

Pada akhirnya, kami bersemangat untuk terus membangun produk kami dengan kinerja dan fungsionalitas pada web yang bebas dan terbuka.