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

إنّ المرئيات الصوتية مهمة للغاية لتعديل الفيديوهات. لفهم السبب، ألق نظرة على لقطة شاشة من المحرر:

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

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

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