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) và các công cụ hiệu suất.

Joshua Grossberg
Joshua Grossberg

Mức độ xem video trực tuyến tăng nhanh kể từ khi đại dịch bắt đầu. Mọi người đang dành nhiều thời gian hơn cho việc phát video chất lượng cao bất tận 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.

Các công ty như Kapwing giúp chúng tôi tạo ra tất cả nội dung video này ngay trên web, bằng cách sử dụng các 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 nền tảng web, chủ yếu dành cho các nhà sáng tạo thông thường như người phát trực tuyến trò chơi, nhạc sĩ, nhà sáng tạo trên YouTube và người dùng meme. Đây cũng là tài nguyên nên dùng cho những chủ doanh nghiệp cần một cách đơn giản để sản xuất nội dung trên mạng xã hội của riêng mình, 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ể, ví dụ: "cách cắt video", "thêm nhạc vào video của tôi" hoặc "đổi kích thước video". Họ có thể thực hiện những việc họ tìm kiếm chỉ bằng một lần nhấp chuột mà không cần thêm phiền hà khi 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 họ cần trợ giúp và 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. Họ có thể khám phá các mẫu miễn phí, thêm các lớp video có sẵn miễn phí mới, chèn phụ đề, chép lời video và tải nhạc nền lên.

Cách Kapwing mang đến khả năng chỉnh sửa và cộng tác theo thời gian thực cho web

Mặc dù web cung cấp các ưu điểm riêng, nhưng cũng có những thách thức riêng biệt. 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 nhằm đạt được mục tiêu về hiệu suất và tính năng.

IndexedDB

Việc 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 phải nằm trên ứng dụng, tránh mạng 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 một lần vào một nội dung, khách hàng của chúng tôi có thể sử dụng lại tài sản của họ 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 cho người dùng bộ nhớ ổn định giống như hệ thống tệp. Kết quả là hơn 90% yêu cầu về nội dung nghe nhìn trong ứng dụng được thực hiện trên máy. 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 tạo tấm nồi 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à xác định hàm upgrade. Thuộc tính này 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, mà chúng tôi thấy hữu ích trong việc ngăn ngừa 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 này, đâ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, cho dù là thông qua trình tải lên 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 chúng ta để chuyển đổi tuần tự quyền truy cậpIndexedDB. Đâ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ộ.

Bây giờ, hãy xem cách chúng ta truy cập vào tệp. Dưới đây là hàm 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;
}

Chúng ta có cấu trúc dữ liệu riêng của mình, idbCache, dùng để giảm thiểu số lượt truy cập IndexedDB. Mặc dù IndexedDB nhanh nhưng truy cập bộ nhớ cục bộ thì nhanh hơn. Bạn nên sử dụng phương pháp này, miễn là bạn quản lý dung lượng của bộ nhớ đệm.

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

API Web âm thanh

Trực quan hoá âm thanh là yếu tố cực kỳ quan trọng đối với việc biên tập video. Để hiểu lý do, hãy xem ảnh chụp màn hình từ trình chỉnh sửa:

Trình chỉnh sửa của Kapwing có trình đơn dành cho nội dung đa phương tiện, trong đó có một số 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 phân tách video, âm thanh và ảnh động; trình chỉnh sửa canvas với các lựa chọn về chất lượng xuất; bản xem trước video và nhiều chức 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 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 còn 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à độ lõm, trong đó độ dốc 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 nhiều thông tin âm thanh chi tiết hơn với các thung lũng tương ứng với tình trạng giật và tạm dừng.

Nghiên cứu về người dùng của chúng tôi cho thấy rằng nhà sáng tạo thường được các dạng sóng này định hướng khi họ ghép nội dung của mình. API âm thanh trên web cho phép chúng tôi trình bày thông tin này một cách hiệu quả và cập nhật nhanh chóng khi thu phóng hoặc kéo 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 truyền trình trợ giúp này thành phần được lưu trữ trong IndexedDB. 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 mình.

Chúng ta thu thập dữ liệu về audioBuffer bằng hàm khởi tạo AudioContext, nhưng do 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 thành một ArrayBuffer nơi lưu trữ dữ liệu biên độ.

Bản thân API này sẽ 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á hiệu quả. Đó là lý do chúng tôi lấy mẫu xuống mức 200 Hz theo cách thủ công. Chúng tôi thấy mức này đủ để tạo các dạng sóng hữu ích và 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 việc điều hướng 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 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 đối với tính năng kéo/thu phóng dòng thời gian là yếu tố rất quan trọng đối với hiệu suất và khả năng phản hồi của ứng dụng. Nút cổ chai để có được bản vẽ khung mượt mà là giải mã khung mà cho đến gần đây, chúng tôi mới sử dụ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 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, có thể dùng trong trình thực thi web. Điều này sẽ giúp cải thiện khả năng vẽ hình thu nhỏ cho số lượng lớn lớp mà không ảnh hưởng đến hiệu suất của luồng chính. Trong khi quá trình triển khai trình thực thi web vẫn đang diễn ra, chúng tôi sẽ trình bày sơ lược bên dưới về 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 WebCodec, trước tiên, chúng ta cần có một luồng video đã được thêm vào (demuxed). Chúng tôi thêm các tệp mp4s 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 đề cập đến một lớp demuxer mà chúng tôi dùng để đóng gói giao diện thành MP4Box. Chúng tôi một lần nữa truy cập vào tài sản 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 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 hình 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 demuxer khá phức tạp và nằm ngoài phạm vi của bài viết này. Tệp này lưu trữ từng khung trong một mảng có tên là samples. Chúng tôi sử dụng demuxer để tìm khung khoá trước đó gần nhất với dấu thời gian mong muốn, đây là nơi chúng tôi phải bắt đầu giải mã video.

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

Ứng dụng này 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ã cụ thể và độ phân giải đầu vào.
  3. Tạo encodedVideoChunk bằng dữ liệu của demuxer.
  4. Gọi phương thức decodeEncodedFrame.

Chúng ta làm điều này cho đến khi tới 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à có hiệu quả khi các dự án lớn hơn và phức tạp hơn. Một cách để tă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 việc này, chúng tôi 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 vẫn có nhiều hạn chế về khả năng kiểm soát của thẻ video HTML5.

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ể cho phép chúng tôi xác định chính xác dữ liệu nào chúng tôi lưu vào bộ đệm để giúp nâng cao hiệu suất.

Chúng ta cũng có thể làm 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 nhân viên web và có thể thông minh hơn trong việc tìm nạp trước tệp và tạo khung. Chúng tôi nhận thấy có nhiều cơ hội lớn để 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á nền thông minh. Chúng tôi dự định tận dụng TensorFlow.js cho các công việc tinh vi 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 muốn tiếp tục xây dựng sản phẩm của mình với hiệu suất và chức năng giống như gốc trên web mở và miễn phí.