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

Giờ đây, nhà sáng tạo có thể dùng Kapwing để chỉnh sửa nội dung video chất lượng cao trên web nhờ các API mạnh mẽ (như IndexedDB và WebCodecs) và 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 kể từ khi đại dịch bắt đầu. Khán giả ngày càng dành nhiều thời gian để xem vô tận các 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 có thể tạo tất cả nội dung video phù hợp trên web, bằng cách sử dụng các API và công cụ hiệu suất 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. Bây giờ cũng là một nguồn tài nguyên đáng tin cậy dành cho những chủ doanh nghiệp cần một cách thức sản xuất 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 khám phá Kapwing bằng cách tìm kiếm một nhiệm vụ cụ thể, chẳng hạn như "cách cắt bớt video" "thêm nhạc vào video của tôi" hoặc "đổi kích thước video". Chúng có thể làm những gì chỉ bằng một lần nhấp chuột mà không gặp thêm phiền hà nào chuyển đến cửa hàng ứng dụng và tải ứng dụng xuống. Web giúp đơn giản hoá mọi người tìm kiếm chính xác công việc họ cần trợ giúp, sau đó thực hiện việc đó.

Sau lượt nhấp đầu tiên đó, người dùng Kapwing có thể làm được nhiều việc hơn nữa. Chúng có thể khám phá các mẫu miễn phí, thêm lớp mới của các video thương mại 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 đem lại trải nghiệm phát mượt mà và chính xác đối với những video phức tạp các dự án nhiều lớp trên nhiều loại 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

Để chỉnh sửa hiệu suất cao, tất cả người dùng nội dung trực tiếp trên khách hàng, tránh mạng này bất cứ khi nào có thể. Không giống như dịch vụ xem trực tuyến, nơi người dùng thường truy cập vào một phần nội dung một lần, khách hàng của chúng tôi sử dụng lại tài sản của họ thường xuyên, nhiều ngày và thậm chí là 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à trên 90% phương tiện truyền thông các yêu cầu trong ứng dụng được thực hiện cục bộ. Tích hợp IndexedDB vào là rất đơn giản.

Dưới đây là một số mã khởi chạy dạng tấm lò hơi 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à định nghĩa hàm upgrade. Thông tin này dùng cho khởi tạo hoặc cập nhật giản đồ khi cần thiết. Chúng ta vượt qua quy trình xử lý lỗi các lệnh gọi lại, blockedblocking mà chúng tôi thấy hữu ích trong 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 ta về khoá chính keyPath. Trong trường hợp của chúng ta, đây là 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, dù là thông qua trình tải lên hay một tiện ích của bên thứ ba, chúng tôi đều thêm nội dung đó vào thư viện đa phương tiệ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. Đây là yêu cầu 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 tôi có cấu trúc dữ liệu idbCache riêng, dùng để thu nhỏ IndexedDB truy cập. Mặc dù IndexedDB nhanh, nhưng việc truy cập vào bộ nhớ cục bộ lại 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 được dùng để ngăn việc truy cập đồng thời vào IndexedDB, nếu không thì sẽ phổ biến khi tải.

API Web âm thanh

Hình ảnh âm thanh đóng vai trò vô cùng quan trọng trong việc biên tập video. Để hiểu lý do, hãy xem ảnh chụp màn hình của trình chỉnh sửa:

Biên tập viên của Kapwing có một trình đơn cho nội dung đa phương tiện, trong đó có một vài mẫu và phần tử 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; dòng thời gian tách biệt video, âm thanh và hoạt ảnh; trình chỉnh sửa canvas với các lựa chọn chất lượng xuất; bản xem trước video; cùng nhiều tính năng khác.

Đây là một 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 rất 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 để giúp ích cho việc điều hướng giữa các phần. Mặt khác, âm thanh dạng sóng cho thấy các đỉnh và thung lũng, thường tương ứng với khoảng thời gian chết trong bản ghi. Nếu bạn phóng to tiến trình, bạn sẽ thấy nhiều thông tin chi tiết hơn về âm thanh với thung lũng tương ứng với tình trạng gián đoạn và dừng hoạt động.

Nghiên cứu về người dùng của chúng tôi cho thấy rằng các dạng sóng này thường dẫn dắt nhà sáng tạo họ ghép nối nội dung của mình. API âm thanh trên web giúp chúng tôi trình bày thông tin một cách hiệu quả và để cập nhật nhanh chóng trên mức thu phóng hoặc độ xoay của dòng thời gian.

Đoạn mã dưới đây minh hoạ cách chúng tôi 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 chuyển thành phần được lưu trữ trong IndexedDB vào trình trợ giúp này. Sau khi hoàn thành, chúng tôi sẽ cập nhật nội dung trong IndexedDB cũng như bộ nhớ đệm của riêng chúng tôi.

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

API này tự 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 cho trực quan hơn. Đó 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, là đủ để tạo ra các dạng sóng hữu ích, bắt mắt.

WebCodecs

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

Chúng tôi không thể lưu mọi hình thu nhỏ có thể có vào bộ nhớ đệm khi tải, vậy nên hãy giải mã nhanh chóng trên dòng thời gian kéo/thu phóng rất quan trọng đối với ứng dụng có hiệu suất cao và phản hồi nhanh. Chiến lược phát hành đĩa đơn điểm tắc nghẽn để đạt được bản vẽ khung mượt mà là giải mã khung, mà cho đến khi gần đây, chúng tôi đã sử dụng trình phát video HTML5. Hiệu quả 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 khung hình kết xuất hình ảnh.

Gần đây, chúng tôi đã chuyển sang WebCodecs, có thể được sử dụng trong nhân viên 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. Trong khi web việc triển khai worker vẫn đang được tiến hành, chúng tôi sẽ đưa ra tóm tắt bên dưới cách triển khai luồng chính hiện có.

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 tôi cần có một video được tách luồng. 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 đề cập đến một lớp demuxer mà chúng ta dùng để đóng gói sang MP4Box. Chúng ta một lần nữa truy cập vào tài sả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.

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ộ lọc này bài viết. Tệp này lưu trữ từng khung hình trong một mảng có tiêu đề samples. Chúng tôi dùng demuxer để tìm khung chính gần nhất trước đó với dấu thời gian mong muốn của chúng ta, đó là mà tại đó chúng tôi 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-frame cũng như khung delta nhỏ hơn, thường được gọi là khung p hoặc b. Phải luôn giải mã bắt đầu tại một khung chính.

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

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

Chúng ta làm việc này cho đến khi đạt đến khung hình 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 quy mô thì hiệu suất của video là đăng ít video nhất có thể cùng một lúc. Tuy nhiên, khi chúng tôi điều này, chúng tôi có nguy cơ chuyển đổi chậm và rắc rối. Mặc dù chúng tôi đã phát triển hệ thống lưu các thành phần video vào bộ nhớ đệm để tái sử dụng, có những giới hạn về mức độ kiểm soát mà các thẻ video HTML5 có thể cung cấp.

Trong tương lai, chúng tôi có thể thử 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 về bàn di chuột xuống web worker và chúng tôi có thể thông minh hơn trong việc tìm nạp trước và khung tạo trước. 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 đang dùng cho xóa nền thông minh. Chúng tôi dự định tận dụng TensorFlow.js cho các miền khác các tác vụ phức tạp 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 mừng được tiếp tục xây dựng sản phẩm bằng các công cụ hiệu suất và chức năng trên web mở và miễn phí.