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 những công cụ nhanh chóng và dễ sử dụng để tạo nội dung video.

Các công ty như Kapwing giúp bạn có thể tạo tất cả nội dung video này ngay trên web bằng cách sử dụng các API và công cụ hiệu suất mạnh mẽ 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 hữu ích dành cho những chủ doanh nghiệp cần một cách dễ dàng để tạo nội dung trên mạng xã hội, chẳng hạn như quảng cáo trên Facebook và Instagram.

Mọi người 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ể thực hiện việc mình tìm kiếm chỉ bằng một lần nhấp chuột mà không cần phải chuyển đế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 việc cần được trợ giúp, 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, 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ụ phát 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ọ, vài ngày và thậm chí vài 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 do chúng ta xác định nội bộ, giúp chuyển đổi tuần tự quyền truy cập vào IndexedDB. Đ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ộ.

Bây giờ, hãy xem cách chúng ta truy cập vào các 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 hoá âm thanh là một yếu tố vô cùng quan trọng trong quá trình chỉnh sửa video. Để hiểu 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 kiểu YouTube, thường thấy 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 lắm 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 hiển thị cho phần cứng thiết bị, nên chúng ta sử dụng OfflineAudioContext để hiển thị cho ArrayBuffer, nơi chúng ta sẽ lưu trữ dữ liệu 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 để tạo hình ảnh trực quan 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 nhất định, hình thu nhỏ của bản nhạc sẽ hữu ích hơn cho việc di chuyển trên dòng thời gian so với dạng sóng. 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 ta 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 khi kéo/thu phóng dòng thời gian là yếu tố quan trọng để có một ứng dụng hiệu quả và 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ị giảm trong quá trình kết xuất khung.

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.

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ó luồng video đã giải mã. Chúng ta sẽ giải mã mp4 bằng thư viện mp4box, như minh hoạ dưới đâ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 phải được lưu trữ theo thứ tự byte và phương thức appendBuffer sẽ trả về độ dời của đoạn tiếp theo.

Sau đây là cách chúng ta giải mã một 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ộ giải mã 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 được tạo thành từ các khung hình đầy đủ, được gọi là khung hình chính hoặc khung hình i, cũng như các khung hình delta nhỏ hơn nhiều, thường được gọi là khung hình p hoặc khung hình 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ì khả năng phát chính xác và hiệu quả khi các dự án ngày càng lớ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ộ để lưu các thành phần video vào bộ nhớ đệm nhằm sử dụng lại, nhưng các thẻ video HTML5 có giới hạn về mức độ kiểm soát.

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ể thực hiện tốt hơn việc giảm tải các phép tính lớn trên bàn di chuột cho worker web, đồng thời có thể thông minh hơn về việc tìm nạp trước các tệp và tạo trước khung. 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 tác vụ phức tạp khác như phát hiện đối tượng, trích xuất đặc điểm, chuyển đổi 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ở.