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

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

Joshua Grossberg
Joshua Grossberg

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

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

لمحة عن Kapwin

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

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

وبعد هذه النقرة الأولى، يمكن لمستخدمي 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 API غير متزامنة.

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

بخلاف ذلك، قد يكون الصفيف subscribers المستخدَم لمنع الوصول المتزامن إلى قاعدة البيانات المفهرسة شائعًا عند التحميل.

واجهة برمجة تطبيقات Web Audio

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

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

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

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

ويوضّح المقتطف أدناه كيفية إجراء ذلك:

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 حيث سنخزّن بيانات السعة.

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

WebCodecs

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

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

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

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

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

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

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