Kapwing: ویرایش ویدیویی قدرتمند برای وب

به لطف APIهای قدرتمند (مانند IndexedDB و WebCodecs) و ابزارهای عملکرد، اکنون سازندگان می توانند محتوای ویدیویی با کیفیت بالا را در وب با Kapwing ویرایش کنند.

جاشوا گروسبرگ
Joshua Grossberg

مصرف ویدیوی آنلاین از زمان شروع همه گیری به سرعت رشد کرده است. مردم زمان بیشتری را صرف مصرف ویدیوهای بی پایان با کیفیت بالا در پلتفرم هایی مانند TikTok، Instagram و YouTube می کنند. خلاقان و صاحبان مشاغل کوچک در سراسر جهان به ابزارهای سریع و آسان برای تولید محتوای ویدیویی نیاز دارند.

شرکت‌هایی مانند Kapwing با استفاده از جدیدترین APIهای قدرتمند و ابزارهای عملکرد، امکان ایجاد تمام این محتوای ویدیویی را مستقیماً در وب ایجاد می‌کنند.

درباره کاپوینگ

Kapwing یک ویرایشگر ویدیوی مشارکتی مبتنی بر وب است که عمدتاً برای خلاقان معمولی مانند پخش‌کننده‌های بازی، موسیقی‌دانان، سازندگان YouTube و میم‌آرها طراحی شده است. همچنین منبعی مناسب برای صاحبان مشاغلی است که به یک راه آسان برای تولید محتوای اجتماعی خود مانند تبلیغات فیس بوک و اینستاگرام نیاز دارند.

افراد با جستجوی یک کار خاص، به عنوان مثال «چگونه یک ویدیو را برش دهیم»، «به ویدیوی من موسیقی اضافه کنم» یا «تغییر اندازه یک ویدیو»، Kapwing را کشف می‌کنند. آن‌ها می‌توانند کاری را که جستجو کرده‌اند تنها با یک کلیک انجام دهند—بدون اصطکاک اضافی برای پیمایش به فروشگاه برنامه و دانلود یک برنامه. وب این کار را برای افراد ساده می کند که دقیقاً به دنبال چه کاری هستند و سپس آن را انجام دهند.

پس از اولین کلیک، کاربران 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 تعریف می کنیم. این برای مقداردهی اولیه یا به روز رسانی طرحواره ما در صورت لزوم استفاده می شود. ما تماس‌های مربوط به مدیریت خطا را ارسال می‌کنیم، 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 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 استفاده می شود. در حالی که IndexedDB سریع است، دسترسی به حافظه محلی سریعتر است. ما این روش را توصیه می کنیم تا زمانی که اندازه حافظه پنهان را مدیریت کنید.

آرایه subscribers ، که برای جلوگیری از دسترسی همزمان به IndexedDB استفاده می شود، در غیر این صورت در بارگذاری معمول خواهد بود.

Web Audio API

تجسم صدا برای ویرایش ویدئو بسیار مهم است. برای درک دلیل، به یک اسکرین شات از ویرایشگر نگاهی بیندازید:

ویرایشگر Kapwing منویی برای رسانه‌ها، شامل چندین الگو و عناصر سفارشی، از جمله برخی از قالب‌هایی که مختص پلتفرم‌های خاصی مانند لینکدین هستند، دارد. جدول زمانی که ویدیو، صدا و انیمیشن را از هم جدا می کند. ویرایشگر بوم با گزینه های کیفیت صادرات. پیش نمایش ویدیو؛ و قابلیت های بیشتر

این یک ویدیوی سبک 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 استفاده می‌کنیم که در آن داده‌های دامنه را ذخیره می‌کنیم.

خود API داده ها را با نرخ نمونه بسیار بالاتر از آنچه برای تجسم مؤثر لازم است برمی گرداند. به همین دلیل است که ما به صورت دستی نمونه برداری را تا 200 هرتز کاهش می دهیم که برای شکل موج های مفید و جذاب از نظر بصری کافی است.

وب کدک ها

برای برخی ویدیوها، ریز عکس‌های آهنگ برای پیمایش خط زمانی مفیدتر از شکل موج هستند. با این حال، تولید ریز عکسها نسبت به تولید شکل موج، به منابع بیشتری نیاز دارد.

ما نمی‌توانیم هر تصویر کوچک احتمالی را در زمان بارگذاری کش ذخیره کنیم، بنابراین رمزگشایی سریع در زمان‌بندی/زوم خط زمانی برای یک برنامه کاربردی و پاسخگو بسیار مهم است. گلوگاه دستیابی به ترسیم فریم صاف، رمزگشایی فریم‌ها است، که تا همین اواخر با استفاده از پخش‌کننده ویدیوی HTML5 انجام می‌دادیم. عملکرد آن رویکرد قابل اعتماد نبود و ما اغلب شاهد کاهش پاسخگویی برنامه در طول رندر فریم بودیم.

اخیراً ما به WebCodecs منتقل شده ایم که می تواند در وب کارگران استفاده شود. این باید توانایی ما را برای ترسیم ریز عکسها برای مقادیر زیادی از لایه ها بدون تأثیر بر عملکرد نخ اصلی افزایش دهد. در حالی که پیاده سازی وب کارگر هنوز در حال انجام است، ما در زیر یک طرح کلی از اجرای رشته اصلی موجود ارائه می دهیم.

یک فایل ویدیویی حاوی چندین جریان است: ویدیو، صدا، زیرنویس و غیره که با هم ترکیب شده اند. برای استفاده از WebCodec ها، ابتدا باید یک جریان ویدئویی دموکس شده داشته باشیم. همانطور که در اینجا نشان داده شده است، mp4s را با کتابخانه mp4box demux می کنیم:

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 ذخیره می کند. ما از demuxer برای یافتن نزدیکترین فریم کلید قبلی به زمان مورد نظر خود استفاده می کنیم، جایی که باید رمزگشایی ویدیو را شروع کنیم.

ویدئوها از فریم‌های کامل، معروف به فریم‌های کلیدی یا i، و همچنین فریم‌های دلتا بسیار کوچک‌تر که اغلب به‌عنوان قاب‌های p یا b شناخته می‌شوند، تشکیل شده‌اند. رمزگشایی همیشه باید از یک فریم کلید شروع شود.

برنامه فریم ها را توسط:

  1. نمونه‌برداری از رمزگشا با یک فریم خروجی فراخوانی.
  2. پیکربندی رمزگشا برای کدک خاص و وضوح ورودی.
  3. ایجاد یک encodedVideoChunk با استفاده از داده های demuxer.
  4. فراخوانی متد decodeEncodedFrame .

این کار را تا زمانی انجام می دهیم که به فریم با مهر زمانی مورد نظر برسیم.

بعدش چی؟

ما مقیاس را در صفحه اصلی خود به عنوان توانایی حفظ پخش دقیق و عملکردی با بزرگتر و پیچیده شدن پروژه ها تعریف می کنیم. یکی از راه‌های مقیاس‌سازی عملکرد این است که تا آنجا که ممکن است ویدیوهای کمتری را به‌طور هم‌زمان نصب کنیم، اما وقتی این کار را انجام می‌دهیم، در خطر انتقال آهسته و متلاطم هستیم. در حالی که ما سیستم‌های داخلی را برای ذخیره کردن اجزای ویدیو برای استفاده مجدد توسعه داده‌ایم، محدودیت‌هایی برای کنترل تگ‌های ویدیوی HTML5 وجود دارد.

در آینده، ممکن است سعی کنیم همه رسانه ها را با استفاده از WebCodec ها پخش کنیم. این می تواند به ما امکان دهد در مورد داده هایی که بافر می کنیم بسیار دقیق باشیم که باید به مقیاس عملکرد کمک کند.

ما همچنین می‌توانیم کار بهتری را برای بارگذاری محاسبات بزرگ پد لمسی برای کارمندان وب انجام دهیم، و می‌توانیم در مورد واکشی اولیه فایل‌ها و تولید فریم‌ها هوشمندتر عمل کنیم. ما فرصت‌های زیادی را برای بهینه‌سازی عملکرد کلی برنامه و گسترش عملکرد با ابزارهایی مانند WebGL می‌بینیم.

مایلیم به سرمایه گذاری خود در TensorFlow.js ادامه دهیم، که در حال حاضر از آن برای حذف هوشمند پس زمینه استفاده می کنیم. ما قصد داریم از TensorFlow.js برای کارهای پیچیده دیگری مانند تشخیص اشیا، استخراج ویژگی، انتقال سبک و غیره استفاده کنیم.

در نهایت، ما برای ادامه ساختن محصول خود با عملکرد و عملکردی مشابه بومی در یک وب آزاد و باز هیجان‌زده هستیم.