ตอนนี้ครีเอเตอร์แก้ไขเนื้อหาวิดีโอคุณภาพสูงบนเว็บด้วย Kapwing ได้แล้วโดยใช้ API ที่มีประสิทธิภาพ (เช่น IndexedDB และ WebCodecs) และเครื่องมือเพิ่มประสิทธิภาพ
การบริโภควิดีโอออนไลน์เติบโตอย่างรวดเร็วตั้งแต่เริ่มมีการระบาด ผู้คนใช้เวลารับชมวิดีโอคุณภาพสูงไม่รู้จบบน แพลตฟอร์มต่างๆ เช่น TikTok, Instagram และ YouTube มากขึ้น ครีเอทีฟและเจ้าของธุรกิจขนาดเล็กทั่วโลกต้องการเครื่องมือที่ง่ายและรวดเร็วในการสร้างเนื้อหาวิดีโอ
บริษัทต่างๆ เช่น Kapwing สามารถสร้างเนื้อหาวิดีโอทั้งหมดนี้บนเว็บได้โดยตรง โดยใช้ API และเครื่องมือประสิทธิภาพล่าสุดที่มีประสิทธิภาพ
เกี่ยวกับ Kapwing
Kapwing เป็นโปรแกรมตัดต่อวิดีโอแบบทำงานร่วมกันบนเว็บที่ออกแบบมาเพื่อครีเอทีฟโฆษณาทั่วไปโดยเฉพาะ เช่น สตรีมเมอร์เกม นักดนตรี ครีเอเตอร์ YouTube และ Meme นอกจากนี้ยังเป็นแหล่งข้อมูลสำหรับเจ้าของธุรกิจที่ต้องการวิธีง่ายๆ ในการผลิตเนื้อหาโซเชียลของตนเอง เช่น โฆษณา Facebook และ Instagram
ผู้คนค้นพบ Kapwing ด้วยการค้นหางานที่เจาะจง เช่น "วิธีตัดวิดีโอ" "เพิ่มเพลงลงในวิดีโอของฉัน" หรือ "ปรับขนาดวิดีโอ" ผู้ใช้สามารถทำในสิ่งที่ค้นหาได้ในคลิกเดียว โดยไม่ต้องยุ่งยากในการไปยัง App Store และดาวน์โหลดแอป เว็บช่วยให้ผู้ใช้ค้นหางานที่ต้องการความช่วยเหลืออย่างแม่นยำได้อย่างง่ายดาย
หลังจากคลิกครั้งแรกแล้ว ผู้ใช้ Kapwing สามารถทำอะไรได้อีกมากมาย เด็กๆ สามารถดูเทมเพลตฟรี เพิ่มชั้นใหม่ๆ ของวิดีโอฟรีใหม่ๆ แทรกคำบรรยาย ถอดเสียงวิดีโอ และอัปโหลดเพลงที่เล่นอยู่เบื้องหลัง
Kapwing นำการแก้ไขและการทำงานร่วมกันแบบเรียลไทม์มาสู่เว็บได้อย่างไร
แม้ว่าเว็บจะมีข้อได้เปรียบที่ไม่ซ้ำใคร แต่ก็เป็นความท้าทายที่ไม่เหมือนกัน Kapwing ต้องการนำเสนอการเล่นโปรเจ็กต์ที่ซับซ้อนแบบหลายชั้นในอุปกรณ์และสภาพเครือข่ายที่หลากหลายได้อย่างราบรื่นและแม่นยำ เพื่อให้บรรลุเป้าหมายนี้ เราใช้ API ต่างๆ ของเว็บเพื่อให้บรรลุเป้าหมายด้านประสิทธิภาพและฟีเจอร์ต่างๆ
IndexedDB
การแก้ไขที่มีประสิทธิภาพสูงกำหนดให้เนื้อหาทั้งหมดของผู้ใช้ต้องเผยแพร่อยู่ในไคลเอ็นต์ และหลีกเลี่ยงเครือข่ายทุกครั้งที่ทำได้ ซึ่งต่างจากบริการสตรีมมิงที่โดยทั่วไปแล้วผู้ใช้จะเข้าถึงเนื้อหาหนึ่งๆ เพียงครั้งเดียว ลูกค้าของเรานำชิ้นงานของตนไปใช้ซ้ำบ่อยครั้ง หลายวัน หรือแม้กระทั่งหลายเดือนหลังจากอัปโหลด
IndexedDB ช่วยให้เรามอบพื้นที่เก็บข้อมูลที่คล้ายกับระบบไฟล์ถาวรแก่ผู้ใช้ของเรา ผลที่ได้คือคำขอสื่อในแอปมากกว่า 90% จะได้รับการตอบสนองในเครื่อง การผสานรวม IndexedDB เข้ากับระบบ ของเราทำได้ง่ายมาก
โค้ดการเริ่มต้นเพลตต้นแบบที่ทำงานในการโหลดแอปมีดังนี้
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');
},
}
);
เราจะส่งผ่านเวอร์ชันและกำหนดฟังก์ชัน upgrade
ซึ่งจะใช้ในการเริ่มต้นหรืออัปเดตสคีมาเมื่อจำเป็น เราส่งต่อโค้ดเรียกกลับสำหรับจัดการข้อผิดพลาด (blocked
) และ blocking
ซึ่งพบว่ามีประโยชน์ในการป้องกันปัญหาสำหรับผู้ใช้ที่มีระบบไม่เสถียร
สุดท้าย ดูคำจำกัดความของคีย์หลัก keyPath
ในกรณีของเรา นี่คือรหัสที่ไม่ซ้ำกันที่เราเรียกว่า mediaLibraryID
เมื่อผู้ใช้เพิ่มสื่อลงในระบบของเรา ไม่ว่าจะผ่านเครื่องมืออัปโหลดหรือส่วนขยายของบุคคลที่สาม เราจะเพิ่มสื่อนั้นลงในคลังสื่อของเราด้วยรหัสต่อไปนี้
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
เป็นฟังก์ชันที่เรากำหนดภายในเองที่ทำให้การเข้าถึง IndexedDB เป็นอนุกรม ซึ่งจำเป็นสำหรับการดำเนินการประเภท Read-modify-write
เนื่องจาก IndexedDB API เป็นแบบไม่พร้อมกัน
คราวนี้มาดูวิธีที่เราใช้เข้าถึงไฟล์กัน ด้านล่างนี้เป็นฟังก์ชัน 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;
}
เรามีโครงสร้างข้อมูล idbCache
ของเราเองที่ใช้เพื่อลดการเข้าถึง IndexedDB แม้ว่า IndexedDB จะเร็ว แต่การเข้าถึงหน่วยความจำในเครื่องจะรวดเร็วขึ้น เราขอแนะนำให้ใช้วิธีนี้หากคุณจัดการขนาดของแคช
อาร์เรย์ subscribers
ที่ใช้เพื่อป้องกันการเข้าถึง IndexedDB พร้อมกันอาจเป็นแบบทั่วไปเมื่อโหลด
API เสียงบนเว็บ
การแสดงข้อมูลผ่านภาพเป็นสิ่งที่สำคัญมากสำหรับการตัดต่อวิดีโอ เพื่อทำความเข้าใจสาเหตุ โปรดดูภาพหน้าจอจากเครื่องมือแก้ไข
นี่เป็นวิดีโอสไตล์ YouTube ซึ่งพบได้ทั่วไปในแอปของเรา ผู้ใช้ไม่ได้เคลื่อนไหวมากนักตลอดทั้งคลิป ดังนั้นภาพขนาดย่อที่มีภาพลำดับเวลาจึงไม่มีประโยชน์สำหรับการไปยังส่วนต่างๆ ระหว่างส่วนต่างๆ ในทางตรงกันข้าม รูปแบบคลื่นเสียงจะแสดงจุดสูงสุดและต่ำสุด โดยกราฟจะสอดคล้องกับช่วงหมดเวลาที่ใช้ในการบันทึก ถ้าซูมเข้าไปในไทม์ไลน์ คุณจะเห็นข้อมูลเสียงที่ละเอียดขึ้น โดยมีวาเลเลย์ที่สอดคล้องกับอาการกระตุกและการหยุดชั่วคราว
การวิจัยผู้ใช้ของเราแสดงให้เห็นว่าครีเอเตอร์มักได้รับคำแนะนำจากรูปแบบคลื่นเหล่านี้ขณะประกอบเนื้อหาเข้าด้วยกัน Web Audio API ช่วยให้เรานำเสนอข้อมูลนี้ได้อย่างมีประสิทธิภาพและอัปเดตได้อย่างรวดเร็วในการซูมหรือเลื่อนไทม์ไลน์
ตัวอย่างด้านล่างแสดงวิธีที่เราดำเนินการ
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;
}
);
เราจะส่งเนื้อหาที่จัดเก็บไว้ใน IndexedDB ให้กับตัวช่วยนี้ เมื่อดำเนินการเสร็จแล้ว เราจะอัปเดตเนื้อหาใน IndexedDB รวมถึงแคชของเรา
เรารวบรวมข้อมูลเกี่ยวกับ audioBuffer
ด้วยเครื่องมือสร้าง AudioContext
แต่เนื่องจากเราไม่ได้แสดงผลไปยังฮาร์ดแวร์ของอุปกรณ์ เราจึงใช้ OfflineAudioContext
เพื่อแสดงผลไปยัง ArrayBuffer
ซึ่งเป็นที่ที่เราจะเก็บข้อมูลแอมพลิจูดไว้
ตัว API เองจะส่งคืนข้อมูลในอัตราตัวอย่างที่สูงกว่าที่จำเป็นสำหรับการแสดงภาพอย่างมีประสิทธิภาพ นั่นคือเหตุผลที่เราลดการสุ่มตัวอย่างความถี่ลงเป็น 200 Hz ซึ่งเราพบว่าเพียงพอต่อรูปคลื่นที่มีประโยชน์และสวยงาม
WebCodecs
สำหรับวิดีโอบางรายการ ภาพขนาดย่อของแทร็กจะมีประโยชน์สำหรับการนำทางในไทม์ไลน์มากกว่ารูปคลื่น อย่างไรก็ตาม การสร้างภาพขนาดย่อจะใช้ทรัพยากรมากกว่า การสร้างรูปคลื่น
เราไม่สามารถแคชภาพขนาดย่อที่เป็นไปได้ทั้งหมดเมื่อโหลด ดังนั้นการถอดรหัสที่รวดเร็วบนไทม์ไลน์ แพน/ซูมจึงสำคัญต่อแอปพลิเคชันที่มีประสิทธิภาพและตอบสนองได้ดี คอขวดที่จะทำให้การวาดเฟรมเป็นไปอย่างราบรื่นคือการถอดรหัสเฟรม ซึ่งก่อนหน้านี้เราใช้โปรแกรมเล่นวิดีโอ HTML5 ประสิทธิภาพของวิธีการนี้ไม่น่าเชื่อถือ และเรามักพบว่าการตอบสนองของแอปลดลงในระหว่างการแสดงเฟรม
เมื่อเร็วๆ นี้เราได้ย้ายไปใช้ WebCodecs ซึ่งสามารถใช้ใน Web Worker ได้ วิธีนี้ควรปรับปรุงความสามารถในการวาดภาพขนาดย่อสำหรับเลเยอร์จำนวนมากโดยไม่ส่งผลกระทบต่อประสิทธิภาพของเทรดหลัก แม้ว่าการติดตั้งใช้งาน Web Worker ยังอยู่ระหว่างดำเนินการ เราจะแสดงข้อมูลคร่าวๆ เกี่ยวกับการติดตั้งใช้งานเทรดหลักที่มีอยู่ด้านล่าง
ไฟล์วิดีโอประกอบด้วยสตรีมหลายรายการ กล่าวคือ วิดีโอ เสียง คำบรรยาย และอื่นๆ ระบบจะ "มิกซ์" ไว้ด้วยกัน หากต้องการใช้ WebCodecs ก่อนอื่นเราต้องมีสตรีมวิดีโอที่จำลองข้อมูลแล้ว เรา demux mp4 ด้วยไลบรารี mp4box ตามที่แสดงที่นี่:
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);
}
};
ข้อมูลโค้ดนี้อ้างถึงคลาส demuxer
ที่เราใช้เพื่อสรุปอินเทอร์เฟซไปยัง MP4Box
เราได้เข้าถึงเนื้อหาจาก IndexedDB อีกครั้ง กลุ่มเหล่านี้ไม่จำเป็นต้องจัดเก็บตามลำดับไบต์ และเมธอด appendBuffer
จะแสดงผลออฟเซ็ตของกลุ่มถัดไป
วิธีที่เราถอดรหัสเฟรมวิดีโอมีดังนี้
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 };
};
โครงสร้างของ Demuxer นั้นค่อนข้างซับซ้อนและอยู่นอกเหนือขอบเขตของบทความนี้ โดยจัดเก็บแต่ละเฟรมในอาร์เรย์ชื่อ samples
เราใช้ Demuxer เพื่อหาคีย์เฟรมก่อนหน้าที่ใกล้เคียงกับการประทับเวลาที่ต้องการมากที่สุด ซึ่งเป็นจุดที่ต้องเริ่มการถอดรหัสวิดีโอ
วิดีโอประกอบด้วยเฟรมเต็ม หรือที่เรียกว่าคีย์หรือ i-Frame และมีเฟรมเดลต้าขนาดเล็กกว่าซึ่งมักเรียกว่า P- หรือ b-Frame การถอดรหัสต้องเริ่มต้นที่คีย์เฟรมเสมอ
แอปพลิเคชันจะถอดรหัสเฟรมตาม:
- การเริ่มต้นตัวถอดรหัสด้วยโค้ดเรียกกลับเอาต์พุตเฟรม
- การกำหนดค่าตัวถอดรหัสสำหรับตัวแปลงรหัสและความละเอียดอินพุตที่เฉพาะเจาะจง
- การสร้าง
encodedVideoChunk
โดยใช้ข้อมูลจาก Demuxer - กำลังเรียกใช้เมธอด
decodeEncodedFrame
ทำเช่นนี้จนกว่าจะไปถึงเฟรมที่มีการประทับเวลาที่ต้องการ
ขั้นตอนถัดไปคือ
เรากำหนดขนาดบนฟรอนท์เอนด์เป็นความสามารถในการรักษาการเล่นวิดีโอให้แม่นยำและมีประสิทธิภาพ ในขณะที่โปรเจ็กต์มีขนาดใหญ่และซับซ้อนมากขึ้น วิธีหนึ่งในการปรับขนาดประสิทธิภาพคือการต่อเชื่อมวิดีโอให้น้อยที่สุดเท่าที่จะทำได้ในแต่ละครั้ง แต่เมื่อทำเช่นนี้ เราอาจเสี่ยงที่จะเปลี่ยนภาพช้าและกระตุก แม้ว่าเราได้พัฒนาระบบภายในเพื่อแคชคอมโพเนนต์วิดีโอไว้ใช้ซ้ำ แต่มีข้อจำกัดเกี่ยวกับปริมาณแท็กของวิดีโอ HTML5 ที่ควบคุมได้
ในอนาคต เราอาจพยายามเล่นสื่อทั้งหมดโดยใช้ WebCodecs ซึ่งอาจทำให้เราสามารถอธิบายได้อย่างชัดเจนว่าข้อมูลใดที่เราบัฟเฟอร์ซึ่งจะช่วยปรับขนาดประสิทธิภาพได้
เรายังสามารถลดภาระการคำนวณแทร็กแพดขนาดใหญ่ให้กับเว็บผู้ปฏิบัติงานได้ดียิ่งขึ้น รวมถึงการดึงไฟล์ล่วงหน้าและการสร้างเฟรมล่วงหน้าได้อย่างชาญฉลาดยิ่งขึ้น เราเห็นโอกาสครั้งสำคัญในการเพิ่มประสิทธิภาพแอปพลิเคชันโดยรวมและขยายฟังก์ชันด้วยเครื่องมืออย่าง WebGL
เราต้องการลงทุนใน TensorFlow.js ต่อไป ซึ่งปัจจุบันเราใช้สำหรับการนำพื้นหลังออกอย่างชาญฉลาด เราวางแผนที่จะใช้ประโยชน์จาก TensorFlow.js สำหรับงานที่ซับซ้อนอื่นๆ เช่น การตรวจจับออบเจ็กต์ การแยกฟีเจอร์ การโอนรูปแบบ และอื่นๆ
สุดท้ายนี้ เรารู้สึกตื่นเต้นที่จะได้พัฒนาผลิตภัณฑ์ต่อไปโดยมีประสิทธิภาพและฟังก์ชันการทำงานเหมือนเนทีฟบนเว็บที่เปิดกว้างและไม่มีค่าใช้จ่าย