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

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

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

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

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

เราจะทำเช่นนี้จนกว่าจะพบเฟรมที่มีการประทับเวลาที่ต้องการ

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

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

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

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

เราต้องการลงทุนใน TensorFlow.js ต่อไป ซึ่งปัจจุบันเราใช้สำหรับการนำพื้นหลังออกอย่างชาญฉลาด เราวางแผนที่จะใช้ประโยชน์จาก TensorFlow.js สำหรับงานอื่นๆ ที่ต้องใช้ความซับซ้อน เช่น การตรวจจับวัตถุ การคว้าข้อมูล การเปลี่ยนรูปแบบ และอื่นๆ

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