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 meningkat pesat sejak awal pandemi. Orang menghabiskan lebih banyak waktu untuk menikmati video berkualitas tinggi tanpa henti di platform seperti TikTok, Instagram, dan YouTube. Pemilik bisnis kecil dan kreatif di seluruh dunia membutuhkan alat yang cepat dan mudah digunakan untuk membuat konten video.

Perusahaan seperti Kapwing memungkinkan pembuatan 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 didesain khusus untuk kreator kreatif kasual seperti game-streamer, musisi, kreator YouTube, dan meme-r. Halaman 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 memangkas video", "menambahkan musik ke video saya", atau "mengubah ukuran video". Pengguna dapat melakukan hal yang mereka telusuri hanya dengan sekali klik—tanpa hambatan tambahan untuk membuka app store dan mendownload aplikasi. Web mempermudah pengguna untuk menelusuri dengan tepat tugas yang memerlukan bantuan, lalu melakukannya.

Setelah klik pertama tersebut, pengguna Kapwing dapat melakukan lebih banyak hal. Mereka dapat menjelajahi template gratis, menambahkan lapisan baru 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 yang unik, web juga menghadirkan tantangan yang berbeda. Kapwing perlu menghadirkan pemutaran yang lancar dan akurat pada project multi-lapisan 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 performa tinggi mengharuskan semua konten pengguna berada di klien, sehingga menghindari jaringan jika memungkinkan. Tidak seperti layanan streaming, yang biasanya digunakan pengguna untuk mengakses suatu konten satu kali, pelanggan kami sering menggunakan kembali aset mereka, baik berhari-hari, bahkan berbulan-bulan setelah upload.

IndexedDB memungkinkan kami menyediakan penyimpanan seperti sistem file persisten kepada pengguna kami. Hasilnya adalah lebih dari 90% permintaan media di aplikasi terpenuhi secara lokal. Mengintegrasikan IndexedDB ke dalam sistem kami sangatlah mudah.

Berikut adalah beberapa kode inisialisasi pelat pemanas air yang berjalan saat pemuatan aplikasi:

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 menurut kami berguna dalam mencegah masalah bagi pengguna dengan sistem yang tidak stabil.

Terakhir, perhatikan definisi kunci utama keyPath. Dalam kasus kita, ini adalah ID unik yang kita sebut mediaLibraryID. Saat pengguna menambahkan media ke sistem, baik melalui uploader atau ekstensi pihak ketiga, kami akan menambahkan media tersebut ke koleksi media 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 mengubah akses IndexedDB. Hal ini diperlukan untuk setiap operasi jenis baca-modifikasi-tulis, karena IndexedDB 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 akses IndexedDB. Meskipun IndexedDB lebih cepat, akses memori lokal akan lebih cepat. Sebaiknya lakukan pendekatan ini selama Anda mengelola ukuran cache.

Array subscribers, yang digunakan untuk mencegah akses simultan ke IndexedDB, akan menjadi hal umum saat dimuat.

API Audio Web

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 spesifik 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 gaya YouTube, yang umum terjadi di aplikasi. Pengguna tidak banyak bergerak di sepanjang klip, sehingga thumbnail visual linimasa tidak berguna untuk menavigasi antar-bagian. Di sisi lain, bentuk gelombang audio menunjukkan puncak dan lembah, dengan lembah yang biasanya sesuai dengan waktu mati dalam rekaman. Jika memperbesar linimasa, Anda akan melihat informasi audio yang lebih mendetail dengan lembah yang menunjukkan ketersendatan dan jeda.

Riset pengguna kami menunjukkan bahwa kreator sering kali dipandu oleh bentuk gelombang ini saat memadukan kontennya. Dengan API audio web, kami dapat menyajikan informasi ini secara performa dan melakukan update dengan cepat saat di-zoom atau digeser linimasa.

Cuplikan di bawah ini 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 helper ini ke aset yang disimpan di IndexedDB. Setelah selesai, kita akan memperbarui aset di IndexedDB serta cache kita sendiri.

Kita mengumpulkan data tentang audioBuffer dengan konstruktor AudioContext, tetapi karena 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. Itu sebabnya kami menurunkan sampel 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, menghasilkan thumbnail memerlukan resource yang lebih intensif daripada menghasilkan bentuk gelombang.

Kita tidak dapat meng-cache setiap thumbnail potensial saat dimuat, sehingga dekode cepat pada penggeseran/zoom linimasa sangat penting untuk aplikasi yang berperforma baik dan responsif. Bottleneck untuk mencapai penggambaran frame yang lancar adalah mendekode frame, yang sampai baru-baru ini kami lakukan menggunakan pemutar video HTML5. Performa pendekatan tersebut tidak dapat diandalkan dan kami sering melihat penurunan respons aplikasi selama rendering frame.

Baru-baru ini, kami telah beralih ke WebCodecs, yang dapat digunakan dalam pekerja web. Hal ini akan meningkatkan kemampuan kami dalam menggambar thumbnail untuk sejumlah besar lapisan tanpa memengaruhi performa thread utama. Saat penerapan pekerja web sedang berlangsung, kami akan memberikan ringkasan tentang penerapan thread utama yang sudah ada di bawah.

File video berisi beberapa streaming: video, audio, subtitel, dan sebagainya yang 'digabungkan' bersama-sama. Untuk menggunakan WebCodecs, pertama kita harus memiliki streaming video yang di-demux. Kami demux mp4 dengan pustaka 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 sekali lagi mengakses aset dari IndexedDB. Segmen ini tidak harus disimpan dalam urutan byte, dan bahwa metode appendBuffer menampilkan offset 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 di luar cakupan artikel ini. Kode ini menyimpan setiap frame dalam array berjudul samples. Kita menggunakan demuxer untuk menemukan frame tombol yang paling dekat dengan stempel waktu yang diinginkan, yaitu saat kita harus memulai dekode video.

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

Aplikasi mendekode frame berdasarkan:

  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 melakukan ini hingga mencapai frame dengan stempel waktu yang diinginkan.

Apa langkah selanjutnya?

Kami mendefinisikan skala di frontend sebagai kemampuan untuk mempertahankan pemutaran yang tepat dan berperforma baik seiring meningkatnya ukuran dan kompleksitas project. Salah satu cara untuk menskalakan performa adalah dengan memasang sesedikit mungkin video sekaligus, tetapi saat melakukannya, kami berisiko mengalami transisi yang lambat dan putus-putus. Meskipun kami telah mengembangkan sistem internal guna meng-cache komponen video untuk digunakan kembali, ada batasan terkait seberapa banyak kontrol yang dapat diberikan oleh tag video HTML5.

Pada masa mendatang, kami mungkin akan mencoba memutar semua media menggunakan WebCodecs. Dengan begitu, kami dapat secara akurat mengetahui data yang kami buffer sehingga dapat membantu meningkatkan performa.

Kita juga dapat melakukan pekerjaan yang lebih baik dalam memindahkan 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 untuk memperluas fungsi dengan alat seperti WebGL.

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

Pada akhirnya, kami akan terus mengembangkan produk kami dengan performa dan fungsi yang menyerupai native di web yang gratis dan terbuka.