Kreator kini dapat mengedit konten video berkualitas tinggi di web dengan Kapwing, berkat API yang canggih (seperti IndexedDB dan WebCodecs) serta alat performa.
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 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 multi-lapis 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:
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 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;
}
);
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 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, 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 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:
- Membuat instance decoder dengan callback output frame.
- Mengonfigurasi dekoder untuk codec dan resolusi input tertentu.
- Membuat
encodedVideoChunk
menggunakan data dari demuxer. - 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 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 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.