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

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

Joshua Grossberg
Joshua Grossberg

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

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

เกี่ยวกับ Kapwing

Kapwing เป็นโปรแกรมตัดต่อวิดีโอแบบทำงานร่วมกันบนเว็บที่ออกแบบมาเพื่อครีเอทีฟโฆษณาทั่วไปโดยเฉพาะ เช่น สตรีมเมอร์เกม นักดนตรี ครีเอเตอร์ YouTube และ Meme นอกจากนี้ยังเป็นแหล่งข้อมูลสำหรับเจ้าของธุรกิจที่ต้องการวิธีง่ายๆ ในการผลิตเนื้อหาโซเชียลของตนเอง เช่น โฆษณา 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 พร้อมกันอาจเป็นแบบทั่วไปเมื่อโหลด

API เสียงบนเว็บ

การแสดงข้อมูลผ่านภาพเป็นสิ่งที่สำคัญมากสำหรับการตัดต่อวิดีโอ เพื่อทำความเข้าใจสาเหตุ โปรดดูภาพหน้าจอจากเครื่องมือแก้ไข

เครื่องมือแก้ไขของ Kapwing มีเมนูสำหรับสื่อ รวมถึงเทมเพลตและองค์ประกอบที่กำหนดเองหลายรายการ รวมถึงเทมเพลตบางรายการที่ใช้เฉพาะกับแพลตฟอร์มอย่าง LinkedIn, ไทม์ไลน์ที่แยกวิดีโอ เสียง และภาพเคลื่อนไหวออกจากกัน เครื่องมือแก้ไข Canvas ที่มีตัวเลือกคุณภาพการส่งออก ตัวอย่างวิดีโอ และความสามารถในการทำงานอื่นๆ

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

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

แอปพลิเคชันจะถอดรหัสเฟรมตาม:

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

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

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

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

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

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

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

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