Kapwing: تعديل الفيديوهات بفعالية على الويب

يمكن لصنّاع المحتوى الآن تعديل محتوى الفيديوهات العالية الجودة على الويب باستخدام Kapwing، وذلك بفضل واجهات برمجة التطبيقات القوية (مثل IndexedDB وWebCodecs) وأدوات الأداء.

Joshua Grossberg
Joshua Grossberg

شهد استهلاك الفيديو على الإنترنت نموًا سريعًا منذ بداية الجائحة. يقضي المستخدمون وقتًا أطول في مشاهدة فيديوهات عالية الجودة على منصات مثل TikTok وInstagram وYouTube. يحتاج صنّاع المحتوى وأصحاب المشروعات الصغيرة في جميع أنحاء العالم إلى أدوات سريعة وسهلة الاستخدام لإنشاء محتوى فيديو.

تتيح شركات مثل Kapwing إنشاء كل محتوى الفيديو هذا مباشرةً على الويب، وذلك باستخدام أحدث واجهات برمجة التطبيقات وأدوات الأداء القوية.

لمحة عن Kapwing

‫Kapwing هو محرّر فيديو تعاوني على الويب مصمّم بشكل أساسي لصنّاع المحتوى المبتدئين، مثل منشئي أحداث البث المباشر لألعاب الفيديو والموسيقيين وصنّاع المحتوى على YouTube وصنّاع المحتوى المميّز. وهو يُعدّ أيضًا مرجعًا مفضّلاً لأصحاب الأنشطة التجارية الذين يحتاجون إلى طريقة سهلة لإنشاء المحتوى الخاص بهم على وسائل التواصل الاجتماعي، مثل إعلانات Facebook وInstagram.

يعثر المستخدمون على Kapwing من خلال البحث عن مهمة معيّنة، مثل "كيفية قطع فيديو" أو "إضافة موسيقى إلى الفيديو" أو "تغيير حجم فيديو". ويمكنهم تنفيذ ما يبحثون عنه بنقرة واحدة فقط، بدون الحاجة إلى الانتقال إلى أحد متاجر التطبيقات وتنزيل تطبيق. وتسهّل شبكة الويب على المستخدمين البحث عن المهمة التي يحتاجون إلى المساعدة في إكمالها، ثم تنفيذها.

بعد هذه النقرة الأولى، يمكن لمستخدمي Kapwing تنفيذ الكثير من الإجراءات. ويمكنهم استخدام نماذج مجانية وإضافة طبقات جديدة من الفيديوهات المجانية واستخدام ميزة "التسريب" وتحويل الفيديوهات إلى نص وتحميل موسيقى في الخلفية.

كيف يوفّر Kapwing ميزة التعديل والتعاون في الوقت الفعلي على الويب

على الرغم من أنّ الويب يوفّر مزايا فريدة، إلا أنّه يطرح أيضًا تحديات مختلفة. يجب أن توفّر Kapwing إمكانية تشغيل سلسة ودقيقة لمشاريع معقدة ومتعدّدة الطبقات على مجموعة واسعة من الأجهزة وظروف الشبكة. لتحقيق ذلك، نستخدم مجموعة متنوعة من واجهات برمجة التطبيقات للويب من أجل تحقيق أهداف الأداء والميزات.

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. هذا الإجراء مطلوب لأي عمليات من نوع القراءة والتعديل والكتابة، لأنّ واجهة برمجة التطبيقات 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، وهو شائع في تطبيقنا. لا يتحرك المستخدِم كثيرًا خلال المقطع، لذا فإنّ الصور المصغّرة المرئية للجداول الزمنية ليست مفيدة بالقدر نفسه للتنقّل بين الأقسام. من ناحية أخرى، يعرض مخطط الموجات الصوتي قممًا وديناميكية، ويرتبط عادةً الجزء الديناميكي بالوقت الذي لا يتم فيه تسجيل أي صوت. إذا زادت magnifier في المخطط الزمني، ستظهر لك معلومات صوتية أكثر دقة مع انخفاضات تتوافق مع المقاطع الصوتية المتقطّعة والمتوقفة.

تُظهر الأبحاث التي نجريها على المستخدمين أنّ صنّاع المحتوى يستعينون غالبًا بهذه الموجات الصوتية عند قطع المحتوى. تتيح لنا واجهة برمجة التطبيقات 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.

تُرجِع واجهة برمجة التطبيقات نفسها البيانات بمعدّل أخذ عينات أعلى بكثير من المطلوب لتوفير رسوم بيانية فعالة. لهذا السبب، نخفّض السرعة يدويًا إلى 200 هرتز، وهو معدّل صعدنا أنّه كافٍ لإنشاء منحنيات موجية مفيدة وجذابة من الناحية المرئية.

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 };
};

إنّ بنية أداة فك الترميز معقّدة للغاية وتتجاوز نطاق هذه المقالة. ويخزّن كل إطار في صفيف بعنوان samples. نستخدم أداة فك الترميز لإيجاد أقرب لقطة رئيسية سابقة إلى الطابع الزمني المطلوب، وهو النقطة التي يجب أن نبدأ فيها فك ترميز الفيديو.

تتألف الفيديوهات من إطارات كاملة، تُعرف باسم الإطارات الرئيسية أو الإطارات i، بالإضافة إلى إطارات دلتا أصغر حجمًا، والتي يُشار إليها غالبًا باسم الإطارات p أو b. يجب أن يبدأ فك التشفير دائمًا من إطار رئيسي.

يفكّ التطبيق ترميز اللقطات من خلال:

  1. إنشاء مثيل لبرنامج الترميز باستخدام دالة استدعاء لإخراج اللقطة
  2. ضبط برنامج الترميز لبرنامج ترميز معيّن ودرجة دقة الإدخال
  3. إنشاء encodedVideoChunk باستخدام بيانات من أداة فك الترميز
  4. استدعاء الطريقة decodeEncodedFrame

نواصل ذلك إلى أن نصل إلى اللقطة التي تتضمّن الطابع الزمني المطلوب.

ما هي الخطوات التالية؟

نعرّف التوسّع في الواجهة الأمامية على أنّه القدرة على الحفاظ على تشغيل دقيق و فعّال مع زيادة حجم المشاريع وتعقيدها. تتمثل إحدى طرق تحسين الأداء في دمج أقل عدد ممكن من الفيديوهات في الوقت نفسه، ولكن عند تنفيذ ذلك، نخاطر ببطء الانتقالات وقطعها. على الرغم من أنّنا طوّرنا أنظمة داخلية لتخزين مكوّنات الفيديو مؤقتًا لإعادة استخدامها، هناك قيود على مقدار التحكّم الذي يمكن أن تقدّمه علامات فيديو HTML5.

في المستقبل، قد نحاول تشغيل جميع الوسائط باستخدام WebCodecs. يمكن أن يسمح لنا ذلك بتحديد البيانات التي نخزّنها مؤقتًا بدقة، ما يساعد في تحسين الأداء.

يمكننا أيضًا تحسين أداء عمليات حسابية كبيرة على لوحة اللمس من خلال تحميلها على Web Workers، ويمكننا أن نستخدم ذكاءً أكبر في جلب الملفات مسبقًا وإنشاء اللقطات مسبقًا. نرى فرصًا كبيرة لتحسين أداء التطبيق العام وتوسيع نطاق الوظائف باستخدام أدوات مثل WebGL.

نريد مواصلة استثمارنا في TensorFlow.js، الذي نستخدمه حاليًا للقيام بعمليات إزالة الخلفية الذكية. نخطّط للاستفادة من TensorFlow.js في مهام معقدة أخرى، مثل رصد الأجسام واستخراج الميزات ونقل الأنماط وما إلى ذلك.

في النهاية، نحن متحمّسون لمواصلة تطوير منتجنا الذي يقدّم أداءً ووظائف مماثلة للتطبيقات الأصلية على الويب المفتوح والمجاني.