Kapwing: עריכת וידאו רבת-עוצמה באינטרנט

עכשיו יוצרים יכולים לערוך תוכן וידאו באיכות גבוהה באינטרנט באמצעות Kapwing, הודות לממשקי API חזקים (כמו IndexedDB ו-WebCodecs) וכלים לשיפור הביצועים.

Joshua Grossberg
Joshua Grossberg

מאז תחילת המגפה, צריכת הסרטונים באינטרנט גדלה במהירות. אנשים מקדישים יותר זמן לצפייה בסרטונים באיכות גבוהה בפלטפורמות כמו 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, היה נפוץ בטעינה אחרת.

Web Audio API

תצוגה חזותית של אודיו חשובה מאוד לעריכת סרטונים. כדי להבין למה, אפשר לעיין בצילום המסך הבא מהכלי:

בעורך של Kapwing יש תפריט מדיה שכולל כמה תבניות ורכיבים מותאמים אישית, כולל תבניות מסוימות שספציפיות לפלטפורמות מסוימות כמו LinkedIn. בנוסף, יש בו ציר זמן שמפריד בין וידאו, אודיו ואנימציה, עורך בד עם אפשרויות איכות לייצוא, תצוגה מקדימה של הסרטון ויכולות נוספות.

זהו סרטון בסגנון YouTube, שהוא נפוץ באפליקציה שלנו. המשתמש לא זז הרבה במהלך הקליפ, ולכן התמונות הממוזערות של צירי הזמן לא מועילות במיוחד לניווט בין קטעים. לעומת זאת, בצורת הגל של האודיו מוצגים פסגות ועמקים, כאשר בדרך כלל העמקים תואמים לזמן מת בצילום. אם תגדילו את התצוגה של ציר הזמן, תוכלו לראות מידע מפורט יותר על האודיו, עם קטעים נמוכים יותר שמייצגים גמגומים והשהיות.

מחקרי המשתמשים שלנו מראים שיוצרים נעזרים לעיתים קרובות בצורות הגל האלה כשהם חותכים את התוכן שלהם. ממשק ה-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. כך נוכל לשפר את היכולת שלנו לצייר תמונות ממוזערות למספר רב של שכבות בלי להשפיע על הביצועים של הליבה. ההטמעה של משימות ה-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. תמיד צריך להתחיל את פענוח הנתונים בפריים מפתח.

האפליקציה מפענחת את הפריימים באמצעות:

  1. יצירת מכונה של המפענח באמצעות קריאה חוזרת (callback) של פלט מסגרת.
  2. הגדרת המפענח לקודק ולרזולוציית הקלט הספציפיים.
  3. יצירת encodedVideoChunk באמצעות נתונים מה-demuxer.
  4. קריאה ל-method‏ decodeEncodedFrame.

אנחנו עושים זאת עד שמגיעים לפריים עם חותמת הזמן הרצויה.

מה השלב הבא?

אנחנו מגדירים את היכולת לשמור על רמת ביצועים גבוהה ועל הפעלה מדויקת ככל שהפרויקטים גדלים ומורכבים יותר, כיכולת התאמה לעומס (scaling) בחזית. אחת מהדרכים לשפר את הביצועים היא להוסיף כמה שפחות סרטונים בו-זמנית, אבל כשאנחנו עושים את זה אנחנו עלולים לקבל מעברים איטיים ומקוטעים. פיתחנו מערכות פנימיות לשמירת רכיבי וידאו במטמון לשימוש חוזר, אבל יש מגבלות על מידת הבקרה שאפשר לקבל באמצעות תגי וידאו ב-HTML5.

בעתיד, יכול להיות שננסה להפעיל את כל רכיבי המדיה באמצעות WebCodecs. כך נוכל לבחור בצורה מדויקת מאוד אילו נתונים נשמור במטמון, וכך לשפר את הביצועים.

אנחנו יכולים גם להעביר חישובים גדולים של משטח המגע לעוזרי האינטרנט, ואנחנו יכולים לבצע אחזור מראש של קבצים ויצירה מראש של פריימים בצורה חכמה יותר. אנחנו רואים הזדמנויות רבות לאופטימיזציה של ביצועי האפליקציה הכוללים ולהרחבת הפונקציונליות באמצעות כלים כמו WebGL.

אנחנו רוצים להמשיך להשקיע ב-TensorFlow.js, שאנחנו משתמשים בו כרגע להסרת רקע חכמה. אנחנו מתכננים להשתמש ב-TensorFlow.js למשימות מתוחכמות אחרות, כמו זיהוי אובייקטים, חילוץ מאפיינים, העברת סגנון ועוד.

בסופו של דבר, אנחנו שמחים להמשיך לפתח את המוצר שלנו עם ביצועים ופונקציונליות כמו של רכיבים מובנים באינטרנט פתוח וחופשי.