ตอนนี้ครีเอเตอร์แก้ไขเนื้อหาวิดีโอคุณภาพสูงบนเว็บได้ด้วย Kapwing ได้แล้ว โดยใช้ API ที่มีประสิทธิภาพ (เช่น IndexedDB และ WebCodecs) และเครื่องมือเพิ่มประสิทธิภาพ
การดูวิดีโอออนไลน์เพิ่มขึ้นอย่างรวดเร็วตั้งแต่ช่วงเริ่มต้นของโรคระบาด ผู้คนใช้เวลาในการรับชมวิดีโอคุณภาพสูงที่ไม่มีวันจบสิ้นบนแพลตฟอร์มต่างๆ เช่น TikTok, Instagram และ YouTube มากขึ้น ครีเอเตอร์และผู้เป็นเจ้าของธุรกิจขนาดเล็กทั่วโลกต้องการเครื่องมือที่ใช้งานง่ายและรวดเร็วในการสร้างเนื้อหาวิดีโอ
บริษัทอย่าง Kapwing ช่วยให้สร้างเนื้อหาวิดีโอทั้งหมดนี้บนเว็บได้โดยใช้ API และเครื่องมือประสิทธิภาพที่มีประสิทธิภาพล่าสุด
เกี่ยวกับ Kapwing
Kapwing เป็นโปรแกรมตัดต่อวิดีโอบนเว็บสำหรับการทำงานร่วมกันซึ่งออกแบบมาเพื่อครีเอเตอร์สายงานทั่วไป เช่น สตรีมเมอร์เกม นักดนตรี ครีเอเตอร์ YouTube และมีม นอกจากนี้ ยังเป็นแหล่งข้อมูลที่เจ้าของธุรกิจต้องการวิธีง่ายๆ ในการสร้างเนื้อหาโซเชียลของตนเอง เช่น โฆษณา 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 พร้อมกันจะพบได้ทั่วไปในการโหลด
Web Audio 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 เราต้องแยกข้อมูลสตรีมวิดีโอออกก่อน เราแยกข้อมูล 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
เราใช้โปรแกรมแยกข้อมูลเพื่อค้นหาเฟรมหลักก่อนหน้าที่อยู่ใกล้กับการประทับเวลาที่ต้องการมากที่สุด ซึ่งเป็นจุดที่เราจะต้องเริ่มการถอดรหัสวิดีโอ
วิดีโอประกอบด้วยเฟรมแบบเต็มที่เรียกว่าคีย์เฟรมหรือ i-frame รวมถึงเฟรมเดลต้าขนาดเล็กมากซึ่งมักเรียกว่า p-frame หรือ b-frame การถอดรหัสต้องเริ่มต้นที่คีย์เฟรมเสมอ
แอปพลิเคชันจะถอดรหัสเฟรมโดยทำดังนี้
- การสร้างอินสแตนซ์ตัวถอดรหัสด้วยคอลแบ็กเอาต์พุตเฟรม
- การกำหนดค่าโปรแกรมถอดรหัสสำหรับตัวแปลงรหัสและความละเอียดอินพุตที่เฉพาะเจาะจง
- การสร้าง
encodedVideoChunk
โดยใช้ข้อมูลจากโปรแกรมแยกข้อมูล - การเรียกใช้เมธอด
decodeEncodedFrame
เราดำเนินการเช่นนี้จนกระทั่งถึงเฟรมซึ่งมีการประทับเวลาที่ต้องการ
ขั้นตอนถัดไปคือ
เรากำหนดขนาดในหน้าเว็บว่าความสามารถในการเล่นที่แม่นยำและมีประสิทธิภาพเมื่อโปรเจ็กต์มีขนาดใหญ่ขึ้นและซับซ้อนมากขึ้น วิธีหนึ่งในการปรับประสิทธิภาพคือการต่อเชื่อมวิดีโอให้น้อยที่สุดเท่าที่จะเป็นไปได้พร้อมกัน แต่วิธีนี้อาจทำให้ทรานซิชันช้าและสะดุด แม้ว่าเราได้พัฒนาระบบภายในเพื่อแคชคอมโพเนนต์วิดีโอไว้ใช้ซ้ำ แต่การควบคุมที่แท็กวิดีโอ HTML5 มอบให้ได้ก็ยังมีข้อจํากัดอยู่
ในอนาคต เราอาจพยายามเล่นสื่อทั้งหมดโดยใช้ WebCodecs ซึ่งอาจทำให้เรารู้ได้แม่นยำมากเกี่ยวกับข้อมูลที่เราบัฟเฟอร์ซึ่งจะช่วยปรับขนาดประสิทธิภาพได้
นอกจากนี้เรายังช่วยให้เราขจัดภาระงานคำนวณของแทร็กแพดขนาดใหญ่ให้แก่ผู้ปฏิบัติงานบนเว็บได้ดียิ่งขึ้น และยังเก่งขึ้นในการดึงไฟล์ล่วงหน้าและเฟรมที่สร้างล่วงหน้าได้ด้วย เราเห็นโอกาสสำคัญในการเพิ่มประสิทธิภาพโดยรวมของแอปพลิเคชันและขยายฟังก์ชันการทำงานด้วยเครื่องมืออย่าง WebGL
เราต้องการต่อการลงทุนใน TensorFlow.js ที่ปัจจุบันใช้สำหรับการลบพื้นหลังอัจฉริยะ เราวางแผนที่จะใช้ประโยชน์จาก TensorFlow.js สำหรับงานที่ซับซ้อนอื่นๆ เช่น การตรวจจับออบเจ็กต์ การแยกฟีเจอร์ การโอนรูปแบบ และอื่นๆ
ท้ายที่สุด เรายินดีที่จะพัฒนาผลิตภัณฑ์ต่อไปด้วยประสิทธิภาพและฟังก์ชันการทำงานที่เหมือนแอปบนเว็บในเว็บที่เปิดกว้างและใช้งานได้ฟรี