יוצרים יכולים עכשיו לערוך תכני וידאו באיכות גבוהה באינטרנט באמצעות Kapwing, הודות לממשקי API חזקים (כמו IndexedDB ו-WebCodecs) וכלים לשיפור ביצועים.
מאז תחילת המגפה, צריכת הסרטונים באינטרנט גדלה במהירות. אנשים צורכים יותר זמן בלי הגבלה על סרטונים באיכות גבוהה בפלטפורמות כמו TikTok, Instagram ו-YouTube. יוצרים ובעלי עסקים קטנים ברחבי העולם זקוקים לכלים מהירים וקלים לשימוש ליצירת תוכן וידאו.
חברות כמו Kapwing מאפשרות ליצור את כל תוכן הווידאו הזה ישירות באינטרנט, באמצעות ממשקי ה-API החזקים וכלי הביצועים העדכניים ביותר.
מידע על Kapwing
Kapwing הוא עורך וידאו מבוסס-אינטרנט שמאפשר עבודה משותפת, והוא מיועד בעיקר ליוצרים לא מקצועיים כמו סטרימרים של משחקים, מוזיקאים, יוצרים ב-YouTube ויוצרים של ממים. זהו גם מקור מידע חשוב לבעלי עסקים שזקוקים בדרך קלה ליצירת תוכן משלהם ברשתות חברתיות, כמו מודעות ב-Facebook וב-Instagram.
אנשים מגלים את Kapwing כשהם מחפשים משימות ספציפיות, למשל 'איך לחתוך סרטון', 'איך להוסיף מוזיקה לסרטון' או 'איך לשנות את הגודל של סרטון'. הם יכולים לבצע את הפעולה שהם חיפשו בלחיצה אחת בלבד – בלי הטרחה הנוספת של ניווט לחנות אפליקציות והורדת אפליקציה. האינטרנט מאפשר לאנשים לחפש בקלות את המשימה הספציפית שבה הם זקוקים לעזרה, ואז לבצע אותה.
לאחר הקליק הראשון הזה, משתמשי Kapwing יכולים לעשות הרבה יותר. הם יכולים לגלות תבניות בחינם, להוסיף שכבות חדשות של סרטונים ממאגר בחינם, להוסיף כתוביות, לתמלל סרטונים ולהעלות מוזיקת רקע.
איך Kapwing מאפשרת עריכה ושיתוף פעולה בזמן אמת באינטרנט
האינטרנט מספק יתרונות ייחודיים, אבל יש בה גם אתגרים ייחודיים. כדי לספק הפעלה חלקה ומדויקת של פרויקטים מורכבים עם כמה שכבות במגוון רחב של מכשירים ותנאי רשת, צוות Kapwing צריך להשתמש ב-CDN. כדי לעשות זאת, אנחנו משתמשים במגוון ממשקי 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. הדבר נדרש לכל פעולה מסוג קריאה-שינוי-כתיבה, כי ה-API של IndexedDB הוא אסינכרוני.
עכשיו נראה איך אנחנו ניגשים לקבצים. זוהי הפונקציה 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 של Web Audio
תצוגה חזותית של אודיו חשובה מאוד לעריכת סרטונים. כדי להבין למה, כדאי לעיין בצילום המסך מהעורך:
זהו סרטון בסגנון 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
שבו נאחסן את נתוני ה-amplitude.
ה-API עצמו מחזיר נתונים בקצב דגימה גבוה בהרבה מהנדרש לתצוגה חזותית יעילה. לכן אנחנו מבצעים דגימה לאחור באופן ידני ל-200Hz, שמצאנו שהיא מספיקה ליצירת צורות גל שימושיות ומושכות מבחינה ויזואלית.
WebCodecs
בסרטונים מסוימים, תמונות הממוזערות של הטראקים מועילות יותר לניווט בקו הזמן מאשר צורות הגל. עם זאת, יצירת תמונות ממוזערות צורכת יותר משאבים מיצירת צורות גל.
אנחנו לא יכולים לשמור במטמון כל תמונה ממוזערת פוטנציאלית בזמן הטעינה, ולכן פענוח מהיר של החלקה/הגדלה על ציר הזמן חיוני לאפליקציה רספונסיבית וביצועית. צוואר הבקבוק כדי ליצור שרטוט של פריימים חלקים הוא פענוח פריימים, שעד לאחרונה עשינו באמצעות נגן וידאו HTML5. הביצועים של הגישה הזו לא היו מהימנים, ולעיתים קרובות נתקלנו בירידה ברמת הרספונסיביות של האפליקציה במהלך עיבוד הפריים.
לאחרונה עברנו ל-WebCodecs, שאפשר להשתמש בו ב-web workers. כך נוכל לשפר את היכולת שלנו לשרטט תמונות ממוזערות בכמויות גדולות של שכבות בלי להשפיע על ביצועי ה-thread הראשי. ההטמעה של משימות ה-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, וגם מפריימים דלתא קטנים בהרבה, שנקראים לעיתים קרובות פריימים מסוג p או b. הפענוח צריך תמיד להתחיל בפריים של מפתח.
האפליקציה מפענחת את הפריימים באמצעות:
- יצירת מכונה של המפענח באמצעות קריאה חוזרת (callback) של פלט מסגרת.
- הגדרת המפענח לקודק ולרזולוציית הקלט הספציפיים.
- יצירת
encodedVideoChunk
באמצעות נתונים מה-demuxer. - ביצוע קריאה ל-method
decodeEncodedFrame
.
אנחנו עושים זאת עד שמגיעים לפריים עם חותמת הזמן הרצויה.
מה השלב הבא?
אנחנו מגדירים את היכולת לשמור על רמת ביצועים גבוהה ועל הפעלה מדויקת ככל שהפרויקטים גדלים ומורכבים יותר, כיכולת התאמה לעומס (scaling) בחזית. אחת מהדרכים לשפר את הביצועים היא להוסיף כמה שפחות סרטונים בו-זמנית, אבל כשאנחנו עושים את זה אנחנו עלולים לקבל מעברים איטיים ומקוטעים. פיתחנו מערכות פנימיות לשמירת רכיבי וידאו במטמון לשימוש חוזר, אבל יש מגבלות על מידת הבקרה שאפשר לקבל באמצעות תגי וידאו ב-HTML5.
בעתיד, יכול להיות שננסה להפעיל את כל רכיבי המדיה באמצעות WebCodecs. כך נוכל לבחור במדויק אילו נתונים נשמור במטמון, וכך לשפר את הביצועים.
אנחנו יכולים גם להעביר חישובים גדולים של משטח המגע לעוזרי האינטרנט, ואנחנו יכולים לבצע אחזור מראש של קבצים ויצירה מראש של פריימים בצורה חכמה יותר. אנחנו רואים הזדמנויות גדולות לבצע אופטימיזציה של הביצועים הכוללים של האפליקציות שלנו ולהרחיב את הפונקציונליות באמצעות כלים כמו WebGL.
אנחנו רוצים להמשיך את ההשקעה שלנו ב-TensorFlow.js, שבו אנחנו משתמשים כרגע להסרת רקע חכמה. אנחנו מתכננים להשתמש ב-TensorFlow.js למשימות מתוחכמות אחרות, כמו זיהוי אובייקטים, חילוץ מאפיינים, העברת סגנון ועוד.
בסופו של דבר, אנחנו שמחים להמשיך לפתח את המוצר שלנו עם ביצועים ופונקציונליות כמו של רכיבים מובנים באינטרנט פתוח וחופשי.