Kapwing: การตัดต่อวิดีโอที่มีประสิทธิภาพสำหรับเว็บ

ตอนนี้ครีเอเตอร์แก้ไขเนื้อหาวิดีโอคุณภาพสูงบนเว็บได้ด้วย Kapwing ได้แล้ว โดยใช้ API ที่มีประสิทธิภาพ (เช่น IndexedDB และ WebCodecs) และเครื่องมือเพิ่มประสิทธิภาพ

Joshua Grossberg
Joshua Grossberg

การดูวิดีโอออนไลน์เพิ่มขึ้นอย่างรวดเร็วตั้งแต่ช่วงเริ่มต้นของโรคระบาด ผู้คนใช้เวลาในการรับชมวิดีโอคุณภาพสูงที่ไม่มีวันจบสิ้นบนแพลตฟอร์มต่างๆ เช่น TikTok, Instagram และ YouTube มากขึ้น ครีเอเตอร์และผู้เป็นเจ้าของธุรกิจขนาดเล็กทั่วโลกต้องการเครื่องมือที่ใช้งานง่ายและรวดเร็วในการสร้างเนื้อหาวิดีโอ

บริษัทอย่าง Kapwing ช่วยให้สร้างเนื้อหาวิดีโอทั้งหมดนี้บนเว็บได้โดยใช้ API และเครื่องมือประสิทธิภาพที่มีประสิทธิภาพล่าสุด

เกี่ยวกับ Kapwing

Kapwing เป็นโปรแกรมตัดต่อวิดีโอบนเว็บสำหรับการทำงานร่วมกันซึ่งออกแบบมาเพื่อครีเอเตอร์สายงานทั่วไป เช่น สตรีมเมอร์เกม นักดนตรี ครีเอเตอร์ YouTube และมีม นอกจากนี้ ยังเป็นแหล่งข้อมูลที่เจ้าของธุรกิจต้องการวิธีง่ายๆ ในการสร้างเนื้อหาโซเชียลของตนเอง เช่น โฆษณา Facebook และ Instagram

ผู้ใช้ค้นพบ Kapwing โดยการค้นหางานเฉพาะ เช่น "วิธีตัดวิดีโอ" "เพิ่มเพลงลงในวิดีโอ" หรือ "ปรับขนาดวิดีโอ" ผู้ใช้สามารถทำสิ่งต่างๆ ที่ค้นหาได้ในคลิกเดียว โดยไม่ต้องยุ่งยากกับการนำทางไปยัง App Store และการดาวน์โหลดแอป เว็บทำให้ผู้ใช้สามารถค้นหาสิ่งที่ต้องการความช่วยเหลือได้อย่างแม่นยำ แล้วทำด้วยเว็บ

หลังจากคลิกครั้งแรกแล้ว ผู้ใช้ 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 ซึ่งจำเป็นสำหรับการดำเนินการประเภท Read-modify-write เนื่องจาก 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 มีเมนูสำหรับสื่อ ซึ่งรวมถึงเทมเพลตและองค์ประกอบที่กำหนดเองหลายรายการ รวมถึงเทมเพลตบางรายการสำหรับแพลตฟอร์มบางประเภท เช่น LinkedIn ไทม์ไลน์ที่แยกวิดีโอ เสียง และภาพเคลื่อนไหว เครื่องมือแก้ไขภาพพิมพ์แคนวาสที่มีตัวเลือกคุณภาพการส่งออก ตัวอย่างวิดีโอ และความสามารถอื่นๆ

นี่เป็นวิดีโอสไตล์ YouTube ซึ่งพบได้ทั่วไปในแอปของเรา ผู้ใช้จะไม่ได้เคลื่อนที่ไปมาตลอดทั้งคลิป ดังนั้นภาพขนาดย่อที่เป็นภาพบนไทม์ไลน์จึงไม่มีประโยชน์สำหรับการนำทางระหว่างส่วนต่างๆ ในทางกลับกัน รูปคลื่นเสียงจะแสดงจุดสูงสุดและจุดต่ำ โดยปกติแล้วจุดต่ำจะสอดคล้องกับช่วงพักระหว่างการบันทึก หากซูมไทม์ไลน์เข้า คุณจะเห็นข้อมูลเสียงที่ละเอียดยิ่งขึ้น โดยมีจุดต่ำสุดที่สอดคล้องกับการกระตุกและการหยุดชั่วคราว

การวิจัยผู้ใช้ของเราแสดงให้เห็นว่าครีเอเตอร์มักจะทำตามรูปแบบคลื่นเหล่านี้ เมื่อมีการต่อประสานเนื้อหา 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 ซึ่งเราจะจัดเก็บข้อมูลระดับความดัง

API เองจะแสดงผลข้อมูลที่อัตราตัวอย่างสูงกว่าที่ต้องใช้ในการสร้างภาพอย่างมีประสิทธิภาพ เราจึงลดขนาดเป็น 200 Hz ด้วยตนเอง ซึ่งเราพบว่าเพียงพอสำหรับรูปแบบคลื่นที่มีประโยชน์และน่าดึงดูด

WebCodecs

สำหรับวิดีโอบางรายการ ภาพขนาดย่อของแทร็กมีประโยชน์สำหรับการนำทาง บนไทม์ไลน์มากกว่ารูปแบบคลื่น อย่างไรก็ตาม การสร้างภาพปกต้องใช้ทรัพยากรมากกว่าการสร้างรูปแบบคลื่น

เราไม่สามารถแคชภาพขนาดย่อที่เป็นไปได้ทั้งหมดเมื่อโหลด ดังนั้นการถอดรหัสอย่างรวดเร็วในไทม์ไลน์หรือการเลื่อน/ซูมจึงมีความสำคัญต่อแอปพลิเคชันที่มีประสิทธิภาพและตอบสนองได้ดี ปัญหาคอขวดในการวาดเฟรมให้ราบรื่นคือการถอดรหัสเฟรม ซึ่งก่อนหน้านี้เราใช้โปรแกรมเล่นวิดีโอ HTML5 ประสิทธิภาพของแนวทางดังกล่าวไม่น่าเชื่อถือ และเรามักจะเห็นการตอบสนองของแอปลดลงระหว่างการแสดงผลเฟรม

และเมื่อเร็วๆ นี้ เราได้เปลี่ยนไปใช้ WebCodecs ซึ่งสามารถใช้ใน Web Worker ซึ่งน่าจะช่วยเพิ่มความสามารถในการวาดภาพขนาดย่อสำหรับเลเยอร์จำนวนมากโดยไม่ส่งผลกระทบต่อประสิทธิภาพของชุดข้อความหลัก ในขณะที่การติดตั้งใช้งาน 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 };
};

โครงสร้างของ Demuxer นั้นค่อนข้างซับซ้อนและอยู่นอกเหนือขอบเขตของบทความนี้ โดยจะจัดเก็บแต่ละเฟรมในอาร์เรย์ชื่อ samples เราใช้โปรแกรมแยกข้อมูลเพื่อค้นหาเฟรมหลักก่อนหน้าที่อยู่ใกล้กับการประทับเวลาที่ต้องการมากที่สุด ซึ่งเป็นจุดที่เราจะต้องเริ่มการถอดรหัสวิดีโอ

วิดีโอประกอบด้วยเฟรมแบบเต็มที่เรียกว่าคีย์เฟรมหรือ i-frame รวมถึงเฟรมเดลต้าขนาดเล็กมากซึ่งมักเรียกว่า p-frame หรือ b-frame การถอดรหัสต้องเริ่มต้นที่คีย์เฟรมเสมอ

แอปพลิเคชันจะถอดรหัสเฟรมโดยทำดังนี้

  1. การสร้างอินสแตนซ์ตัวถอดรหัสด้วยคอลแบ็กเอาต์พุตเฟรม
  2. การกำหนดค่าโปรแกรมถอดรหัสสำหรับตัวแปลงรหัสและความละเอียดอินพุตที่เฉพาะเจาะจง
  3. การสร้าง encodedVideoChunk โดยใช้ข้อมูลจากโปรแกรมแยกข้อมูล
  4. การเรียกใช้เมธอด decodeEncodedFrame

เราดำเนินการเช่นนี้จนกระทั่งถึงเฟรมซึ่งมีการประทับเวลาที่ต้องการ

ขั้นตอนถัดไปคือ

เรากำหนดขนาดในหน้าเว็บว่าความสามารถในการเล่นที่แม่นยำและมีประสิทธิภาพเมื่อโปรเจ็กต์มีขนาดใหญ่ขึ้นและซับซ้อนมากขึ้น วิธีหนึ่งในการปรับประสิทธิภาพคือการต่อเชื่อมวิดีโอให้น้อยที่สุดเท่าที่จะเป็นไปได้พร้อมกัน แต่วิธีนี้อาจทำให้ทรานซิชันช้าและสะดุด แม้ว่าเราได้พัฒนาระบบภายในเพื่อแคชคอมโพเนนต์วิดีโอไว้ใช้ซ้ำ แต่การควบคุมที่แท็กวิดีโอ HTML5 มอบให้ได้ก็ยังมีข้อจํากัดอยู่

ในอนาคต เราอาจพยายามเล่นสื่อทั้งหมดโดยใช้ WebCodecs ซึ่งอาจทำให้เรารู้ได้แม่นยำมากเกี่ยวกับข้อมูลที่เราบัฟเฟอร์ซึ่งจะช่วยปรับขนาดประสิทธิภาพได้

นอกจากนี้เรายังช่วยให้เราขจัดภาระงานคำนวณของแทร็กแพดขนาดใหญ่ให้แก่ผู้ปฏิบัติงานบนเว็บได้ดียิ่งขึ้น และยังเก่งขึ้นในการดึงไฟล์ล่วงหน้าและเฟรมที่สร้างล่วงหน้าได้ด้วย เราเห็นโอกาสสำคัญในการเพิ่มประสิทธิภาพโดยรวมของแอปพลิเคชันและขยายฟังก์ชันการทำงานด้วยเครื่องมืออย่าง WebGL

เราต้องการต่อการลงทุนใน TensorFlow.js ที่ปัจจุบันใช้สำหรับการลบพื้นหลังอัจฉริยะ เราวางแผนที่จะใช้ประโยชน์จาก TensorFlow.js สำหรับงานที่ซับซ้อนอื่นๆ เช่น การตรวจจับออบเจ็กต์ การแยกฟีเจอร์ การโอนรูปแบบ และอื่นๆ

ท้ายที่สุด เรายินดีที่จะพัฒนาผลิตภัณฑ์ต่อไปด้วยประสิทธิภาพและฟังก์ชันการทำงานที่เหมือนแอปบนเว็บในเว็บที่เปิดกว้างและใช้งานได้ฟรี