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

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

Joshua Grossberg
Joshua Grossberg

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

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

מידע על Kapwing

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

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

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

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

העורך של 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 באמצעות ה-constructor של AudioContext, אבל בגלל שאנחנו לא מבצעים רינדור לחומרת המכשיר, אנחנו משתמשים ב-OfflineAudioContext כדי לבצע רינדור ב-ArrayBuffer, שבו נאחסן נתוני משרעת.

ה-API עצמו מחזיר נתונים בקצב דגימה גבוה בהרבה מהנדרש כדי לבצע הדמיה יעילה. לכן אנחנו מצמצמים את הדגימה באופן ידני ל-200Hz, וגילינו שהוא מספיק לצורות גל שימושיות ומושכות מבחינה חזותית.

WebCodecs

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

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

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

קובץ וידאו מכיל מספר זרמים: וידאו, אודיו, כתוביות וכן הלאה, ש 'מעורבים' יחד. כדי להשתמש ב-WebCodecs, קודם יש צורך ב-Video Stream Demuxed. אנחנו יוצרים קובצי mp4s באמצעות ספריית 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 };
};

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

סרטונים מורכבים מפריימים מלאים, המכונים מפתח (key) או i-frames, וכן ממסגרות דלתא קטנות יותר, שנקראות לעיתים קרובות מסגרות p- או b-frames. הפענוח חייב להתחיל תמיד במסגרת של מפתח.

האפליקציה מפענחת מסגרות על ידי:

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

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

מה השלב הבא?

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

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

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

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

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