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 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. Penting juga merupakan sumber daya utama bagi pemilik bisnis yang membutuhkan cara mudah untuk memproduksi 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 keunggulan unik, web juga menghadirkan tantangan yang berbeda. Kapwing perlu menghadirkan pemutaran yang kompleks dan akurat, 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 pengguna kami ditayangkan di klien, sehingga menghindari jaringan jika 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;
}

Kita memiliki struktur data kita sendiri, idbCache, yang digunakan untuk meminimalkan akses IndexedDB. Meskipun FragmentManager 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, waveform audio menunjukkan puncak dan lembah, dengan lembah 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 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 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;
    } 
  );

Kita meneruskan aset yang disimpan di IndexedDB ke helper ini. 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' saat digunakan bersama. Untuk menggunakan WebCodecs, pertama-tama kita harus memiliki video yang didemux feed. 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. Kita 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 seiring 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 tugas yang lebih baik dalam mentransfer komputasi trackpad besar ke web worker, 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 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.