Kapwing: Chỉnh sửa video mạnh mẽ dành cho web

Giờ đây, nhà sáng tạo có thể chỉnh sửa nội dung video chất lượng cao trên web bằng Kapwing nhờ các API mạnh mẽ (như IndexedDB và WebCodecs) cũng như các công cụ hiệu suất.

Joshua Grossberg
Joshua Grossberg

Mức tiêu thụ video trực tuyến đã tăng nhanh chóng kể từ khi đại dịch bắt đầu. Mọi người đang dành nhiều thời gian hơn để xem vô số video chất lượng cao trên các nền tảng như TikTok, Instagram và YouTube. Các nhà sáng tạo và chủ doanh nghiệp nhỏ trên khắp thế giới cần các công cụ nhanh chóng và dễ sử dụng để tạo nội dung video.

Những công ty như Kapwing có thể tạo ra tất cả nội dung video này ngay trên web, bằng cách sử dụng công cụ hiệu suất và API mạnh mẽ mới nhất.

Giới thiệu về Kapwing

Kapwing là một trình chỉnh sửa video cộng tác dựa trên web, chủ yếu dành cho những nhà sáng tạo không chuyên như nhà phát trực tiếp trò chơi, nhạc sĩ, nhà sáng tạo trên YouTube và nhà sáng tạo meme. Đây cũng là tài nguyên đáng tin cậy dành cho các chủ doanh nghiệp cần một cách dễ dàng để tự sản xuất nội dung trên mạng xã hội, chẳng hạn như quảng cáo trên Facebook và Instagram.

Người dùng tìm thấy Kapwing bằng cách tìm kiếm một thao tác cụ thể, chẳng hạn như "cách cắt video", "thêm nhạc vào video" hoặc "đổi kích thước video". Họ có thể làm những gì họ đã tìm kiếm chỉ với một cú nhấp chuột mà không gặp phải phiền hà khi điều hướng đến cửa hàng ứng dụng và tải ứng dụng xuống. Web giúp mọi người dễ dàng tìm kiếm chính xác công việc họ cần trợ giúp và sau đó thực hiện việc đó.

Sau lần nhấp đầu tiên đó, người dùng Kapwing có thể làm được nhiều việc hơn nữa. Họ có thể khám phá các mẫu miễn phí, thêm các lớp mới của video miễn phí, chèn phụ đề, chép lời video và tải nhạc nền lên.

Cách Kapwing mang tính năng chỉnh sửa và cộng tác theo thời gian thực lên web

Mặc dù mang lại những lợi thế riêng biệt, nhưng web cũng đặt ra những thách thức riêng. Kapwing cần cung cấp khả năng phát mượt mà và chính xác các dự án phức tạp, nhiều lớp trên nhiều thiết bị và điều kiện mạng. Để đạt được điều này, chúng tôi sử dụng nhiều API web để đạt được mục tiêu về hiệu suất và tính năng.

IndexedDB

Tính năng chỉnh sửa hiệu suất cao yêu cầu tất cả nội dung của người dùng đều nằm trên ứng dụng, tránh sử dụng mạng bất cứ khi nào có thể. Không giống như dịch vụ xem trực tuyến, trong đó người dùng thường truy cập vào một nội dung một lần, khách hàng của chúng tôi thường xuyên sử dụng lại các thành phần của họ một cách thường xuyên, nhiều ngày và thậm chí nhiều tháng sau khi tải lên.

IndexedDB cho phép chúng tôi cung cấp bộ nhớ giống hệ thống tệp ổn định cho người dùng. Kết quả là hơn 90% yêu cầu nội dung nghe nhìn trong ứng dụng được thực hiện cục bộ. Việc tích hợp IndexedDB vào hệ thống của chúng tôi rất đơn giản.

Dưới đây là một số mã khởi chạy nguyên mẫu chạy khi tải ứng dụng:

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');
      },
    }
  );

Chúng ta truyền một phiên bản và xác định một hàm upgrade. Phương thức này được dùng để khởi chạy hoặc cập nhật giản đồ khi cần. Chúng tôi truyền các lệnh gọi lại xử lý lỗi, blockedblocking, chúng tôi nhận thấy các lệnh gọi này hữu ích trong việc ngăn chặn sự cố cho người dùng có hệ thống không ổn định.

Cuối cùng, hãy lưu ý định nghĩa của chúng tôi về khoá chính keyPath. Trong trường hợp của chúng ta, đây là một mã nhận dạng duy nhất mà chúng ta gọi là mediaLibraryID. Khi người dùng thêm một nội dung nghe nhìn vào hệ thống của chúng tôi, cho dù là thông qua trình tải lên của chúng tôi hay tiện ích của bên thứ ba, chúng tôi sẽ thêm nội dung nghe nhìn đó vào thư viện nội dung nghe nhìn bằng mã sau:

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 là hàm được xác định nội bộ của riêng chúng tôi, chuyển đổi tuần tự quyền truy cậpIndexedDB. Điều này là bắt buộc đối với mọi thao tác loại đọc-sửa đổi-ghi, vì API IndexedDB không đồng bộ.

Giờ hãy xem cách chúng ta truy cập vào tệp. Dưới đây là hàm getAsset của chúng ta:

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;
}

Chúng ta có cấu trúc dữ liệu riêng, idbCache, được dùng để giảm thiểu các quyền truy cập vào IndexedDB. Mặc dù IndexedDB rất nhanh, nhưng việc truy cập vào bộ nhớ cục bộ còn nhanh hơn. Bạn nên sử dụng phương pháp này miễn là bạn quản lý kích thước bộ nhớ đệm.

Mảng subscribers dùng để ngăn chặn việc truy cập đồng thời vào IndexedDB, nếu không thì sẽ phổ biến khi tải.

Web Audio API

Hình ảnh âm thanh là yếu tố vô cùng quan trọng trong quá trình chỉnh sửa video. Để biết lý do, hãy xem ảnh chụp màn hình của trình chỉnh sửa:

Trình chỉnh sửa của Kapwing có một trình đơn dành cho nội dung nghe nhìn, bao gồm một số mẫu và thành phần tuỳ chỉnh, trong đó có một số mẫu dành riêng cho một số nền tảng nhất định như LinkedIn; một dòng thời gian phân tách video, âm thanh và ảnh động; trình chỉnh sửa canvas có các tuỳ chọn chất lượng xuất; bản xem trước video; và nhiều tính năng khác.

Đây là video theo phong cách YouTube, phổ biến trong ứng dụng của chúng tôi. Người dùng không di chuyển nhiều trong suốt đoạn video, vì vậy hình thu nhỏ trực quan của dòng thời gian không hữu ích khi di chuyển giữa các phần. Mặt khác, dạng sóng âm thanh cho thấy các đỉnh và các thung lũng, trong đó các thung lũng thường tương ứng với thời gian chết trong bản ghi. Nếu phóng to dòng thời gian, bạn sẽ thấy thông tin chi tiết hơn về âm thanh với các hố tương ứng với tình trạng giật và tạm dừng.

Nghiên cứu của chúng tôi về người dùng cho thấy các nhà sáng tạo thường dựa vào các dạng sóng này khi ghép nội dung. API âm thanh trên web cho phép chúng ta trình bày thông tin này một cách hiệu quả và cập nhật nhanh khi thu phóng hoặc kéo theo dòng thời gian.

Đoạn mã dưới đây minh hoạ cách chúng ta thực hiện việc này:

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;
    } 
  );

Chúng ta truyền cho trình trợ giúp này thành phần được lưu trữ trong IndexedDB. Sau khi hoàn tất, chúng ta sẽ cập nhật tài sản trong IndexedDB cũng như bộ nhớ đệm của riêng mình.

Chúng ta thu thập dữ liệu về audioBuffer bằng hàm khởi tạo AudioContext, nhưng vì không kết xuất đến phần cứng thiết bị, nên chúng ta sử dụng OfflineAudioContext để kết xuất vào ArrayBuffer, tại đó chúng ta sẽ lưu trữ dữ liệu về biên độ.

Bản thân API này trả về dữ liệu ở tốc độ lấy mẫu cao hơn nhiều so với mức cần thiết để trực quan hoá một cách hiệu quả. Đó là lý do chúng tôi giảm tần số lấy mẫu xuống 200 Hz theo cách thủ công. Chúng tôi nhận thấy tần số này là đủ để tạo ra các dạng sóng hữu ích và hấp dẫn về mặt hình ảnh.

WebCodecs

Đối với một số video, hình thu nhỏ của bản nhạc sẽ hữu ích hơn sóng âm để di chuyển trên dòng thời gian. Tuy nhiên, việc tạo hình thu nhỏ tốn nhiều tài nguyên hơn so với việc tạo dạng sóng.

Chúng tôi không thể lưu mọi hình thu nhỏ tiềm năng vào bộ nhớ đệm khi tải. Vì vậy, việc giải mã nhanh trên dòng thời gian kéo/thu phóng là rất quan trọng đối với một ứng dụng có hiệu suất cao và có khả năng thích ứng. Điểm nghẽn để đạt được việc vẽ khung hình mượt mà là giải mã khung hình. Cho đến gần đây, chúng tôi đã thực hiện việc này bằng trình phát video HTML5. Hiệu suất của phương pháp đó không đáng tin cậy và chúng tôi thường thấy khả năng phản hồi của ứng dụng bị suy giảm trong quá trình kết xuất khung hình.

Gần đây, chúng tôi đã chuyển sang WebCodecs. Bạn có thể sử dụng WebCodecs trong worker web. Điều này sẽ giúp cải thiện khả năng vẽ hình thu nhỏ cho nhiều lớp mà không ảnh hưởng đến hiệu suất của luồng chính. Mặc dù việc triển khai worker web vẫn đang diễn ra, nhưng chúng tôi sẽ trình bày tóm tắt dưới đây về cách triển khai luồng chính hiện tại.

Một tệp video chứa nhiều luồng: video, âm thanh, phụ đề, v.v. được "kết hợp" với nhau. Để sử dụng WebCodecs, trước tiên, chúng ta cần có một luồng video đã giải mã. Chúng tôi demux mp4s với thư viện mp4box, như được hiển thị ở đây:

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);
  }
};

Đoạn mã này tham chiếu đến một lớp demuxer mà chúng ta sử dụng để đóng gói giao diện vào MP4Box. Chúng ta truy cập lại thành phần này từ IndexedDB. Các phân đoạn này không nhất thiết được lưu trữ theo thứ tự byte và phương thức appendBuffer sẽ trả về giá trị bù trừ của phân đoạn tiếp theo.

Dưới đây là cách chúng tôi giải mã khung video:

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 };
};

Cấu trúc của bộ phân bổ khá phức tạp và nằm ngoài phạm vi của bài viết này. Lớp này lưu trữ từng khung hình trong một mảng có tên là samples. Chúng ta sử dụng trình phân tách để tìm khung hình chính gần nhất trước đó với dấu thời gian mong muốn, đây là nơi chúng ta phải bắt đầu giải mã video.

Video bao gồm các khung hình đầy đủ, còn gọi là khung hình chính hoặc khung hình i, cũng như các khung delta nhỏ hơn nhiều, thường được gọi là khung p hoặc b. Quá trình giải mã phải luôn bắt đầu tại một khung hình chính.

Ứng dụng giải mã khung hình bằng cách:

  1. Tạo bản sao bộ giải mã bằng lệnh gọi lại đầu ra khung hình.
  2. Định cấu hình bộ giải mã cho bộ mã hoá và giải mã và độ phân giải đầu vào cụ thể.
  3. Tạo encodedVideoChunk bằng dữ liệu từ trình phân tách.
  4. Gọi phương thức decodeEncodedFrame.

Chúng ta thực hiện việc này cho đến khi đạt đến khung có dấu thời gian mong muốn.

Tiếp theo là gì?

Chúng tôi xác định quy mô trên giao diện người dùng là khả năng duy trì chế độ phát chính xác và hiệu quả khi các dự án ngày càng lớn hơn và phức tạp hơn. Một cách để mở rộng hiệu suất là gắn càng ít video càng tốt cùng một lúc, tuy nhiên khi làm như vậy, chúng ta có nguy cơ chuyển đổi chậm và bị giật. Mặc dù chúng tôi đã phát triển các hệ thống nội bộ nhằm lưu các thành phần video vào bộ nhớ đệm để sử dụng lại, nhưng có một số hạn chế về khả năng kiểm soát mà thẻ video HTML5 có thể cung cấp.

Trong tương lai, chúng ta có thể cố gắng phát tất cả nội dung nghe nhìn bằng WebCodecs. Điều này có thể giúp chúng ta xác định chính xác dữ liệu nào được lưu vào bộ đệm để giúp mở rộng hiệu suất.

Chúng ta cũng có thể giảm tải các phép tính lớn cho bàn di chuột cho nhân viên web, đồng thời có thể tìm nạp trước tệp và tạo trước theo cách thông minh hơn. Chúng tôi nhận thấy nhiều cơ hội để tối ưu hoá hiệu suất tổng thể của ứng dụng và mở rộng chức năng bằng các công cụ như WebGL.

Chúng tôi muốn tiếp tục đầu tư vào TensorFlow.js mà chúng tôi hiện đang sử dụng để xoá phông nền một cách thông minh. Chúng tôi dự định tận dụng TensorFlow.js cho các nhiệm vụ phức tạp khác như phát hiện đối tượng, trích xuất tính năng, chuyển kiểu, v.v.

Cuối cùng, chúng tôi rất vui khi tiếp tục xây dựng sản phẩm có hiệu suất và chức năng giống như mã gốc trên một web miễn phí và mở.