Twórcy mogą teraz edytować wysokiej jakości treści wideo w internecie za pomocą Kapwing dzięki potężnym interfejsom API (takim jak IndexedDB i WebCodecs) oraz narzędziom do optymalizacji.
Od początku pandemii popularność filmów online gwałtownie rośnie. Użytkownicy spędzają więcej czasu na oglądaniu niekończących się filmów w wysokiej jakości na platformach takich jak TikTok, Instagram czy YouTube. Twórcy i właściciele małych firm na całym świecie potrzebują szybkich i łatwych w użyciu narzędzi do tworzenia treści wideo.
Firmy takie jak Kapwing umożliwiają tworzenie wszystkich tych treści wideo bezpośrednio w internecie, korzystając z najnowszych interfejsów API i narzędzi do zwiększania skuteczności.
Informacje o Kapwing
Kapwing to internetowy edytor wideo do współpracy, który został zaprojektowany głównie z myślą o twórcach-amatorów, takich jak streamerzy, muzycy, twórcy YouTube i twórcy memów. Jest to też przydatne źródło informacji dla właścicieli firm, którzy potrzebują łatwego sposobu na tworzenie własnych treści społecznościowych, takich jak reklamy na Facebooku i Instagramie.
Użytkownicy odkrywają Kapwinga, wyszukując konkretne zadanie, np. „jak przyciąć film”, „dodać muzykę do filmu” lub „zmienić rozmiar filmu”. Mogą wykonać to, czego szukali, jednym kliknięciem – bez konieczności przechodzenia do sklepu z aplikacjami i pobierania aplikacji. Dzięki internetowi ludzie mogą łatwo znaleźć dokładnie to, czego potrzebują, a potem to zrobić.
Po tym pierwszym kliknięciu użytkownicy Kapwing mogą zrobić znacznie więcej. Mogą oni korzystać z bezpłatnych szablonów, dodawać nowe warstwy bezpłatnych filmów stockowych, wstawiać napisy, transkrybować filmy i przesyłać muzykę do odtwarzania w tle.
Jak Kapwing umożliwia edycję i współpracę w czasie rzeczywistym w internecie
Internet ma wiele zalet, ale wiąże się też z pewnymi wyzwaniami. Kapwing musi zapewniać płynne i dokładne odtwarzanie złożonych projektów wielowarstwowych na wielu urządzeniach i w różnych warunkach sieciowych. Aby to osiągnąć, używamy różnych interfejsów API do przeglądarki, które pomagają nam w osiąganiu celów dotyczących wydajności i funkcji.
IndexedDB
Edytowanie z wysoką wydajnością wymaga, aby wszystkie treści użytkowników były dostępne na kliencie, a nie w sieci, o ile to możliwe. W przeciwieństwie do usług przesyłania strumieniowego, w których użytkownicy zwykle uzyskują dostęp do treści tylko raz, nasi klienci często używają swoich zasobów jeszcze przez wiele dni, a nawet miesięcy po przesłaniu.
IndexedDB umożliwia nam udostępnianie użytkownikom trwałej pamięci w postaci systemu plików. W rezultacie ponad 90% żądań dotyczących multimediów w aplikacji jest realizowanych lokalnie. Integracja IndexedDB z naszym systemem była bardzo prosta.
Oto przykładowy kod inicjujący, który jest wykonywany podczas wczytywania aplikacji:
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');
},
}
);
Przekazujemy wersję i definiujemy funkcję upgrade
. Służy on do inicjalizacji lub aktualizacji schematu w razie potrzeby. Przekazujemy wywołania zwrotne obsługi błędów blocked
i blocking
, które okazały się przydatne w zapobieganiu problemom u użytkowników z niestabilnymi systemami.
Na koniec zapoznaj się z definicją klucza podstawowego keyPath
. W naszym przypadku jest to unikalny identyfikator o nazwie mediaLibraryID
. Gdy użytkownik dodaje do naszego systemu materiał multimedialny (za pomocą naszego narzędzia do przesyłania lub rozszerzenia innej firmy), dodajemy go do naszej biblioteki multimediów za pomocą tego kodu:
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
to nasza własna funkcja zdefiniowana wewnętrznie, która serializuje dostęp do IndexedDB. Jest to wymagane w przypadku wszystkich operacji typu odczyt-modyfikacja-zapis, ponieważ interfejs IndexedDB API jest asynchroniczny.
Teraz przyjrzyjmy się temu, jak uzyskujemy dostęp do plików. Poniżej znajduje się funkcja 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;
}
Mamy własną strukturę danych, idbCache
, która służy do minimalizowania dostępów do IndexedDB. IndexedDB jest szybki, ale dostęp do pamięci lokalnej jest jeszcze szybszy. Zalecamy to podejście, o ile zarządzasz rozmiarem pamięci podręcznej.
Tablica subscribers
, która służy do zapobiegania jednoczesnemu dostępowi do IndexedDB, w przeciwnym razie byłaby częsta podczas wczytywania.
Web Audio API
Wizualizacja dźwięku jest niezwykle ważna w przypadku edycji filmów. Aby to zrozumieć, spójrz na zrzut ekranu z edytowanego dokumentu:
To film w stylu YouTube, który jest powszechny w naszej aplikacji. Użytkownik nie porusza się zbytnio w trakcie klipu, więc wizualne miniatury na osi czasu nie są tak przydatne do poruszania się między sekcjami. Z drugiej strony przebieg fali dźwięku zawiera szczyty i doliny, przy czym doliny zwykle odpowiadają czasowi bez dźwięku w nagraniu. Jeśli przybliżysz osi czasu, zobaczysz bardziej szczegółowe informacje o dźwięku z dodatkowymi dołkami odpowiadającymi za zakłócenia i przerwy.
Nasze badania opinii użytkowników pokazują, że twórcy często kierują się tymi formami fali podczas łączenia treści. Interfejs API do dźwięku w internecie umożliwia nam prezentowanie tych informacji w sposób efektywny i szybkie aktualizowanie ich po powiększeniu lub przesunięciu osi czasu.
Poniżej fragment kodu pokazuje, jak to zrobić:
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;
}
);
Przekazujemy temu helperowi zasób przechowywany w IndexedDB. Po zakończeniu procesu zaktualizujemy zasób w IndexedDB oraz w naszej własnej pamięci podręcznej.
Dane o audioBuffer
zbieramy za pomocą konstruktora AudioContext
, ale ponieważ nie renderujemy na sprzęcie urządzenia, używamy funkcji OfflineAudioContext
do renderowania na potrzeby ArrayBuffer
, w której będziemy przechowywać dane z Amplitude.
Interfejs API zwraca dane z częstotliwością próbkowania znacznie wyższą niż potrzebna do skutecznej wizualizacji. Dlatego ręcznie zmniejszamy częstotliwość próbkowania do 200 Hz, co jest wystarczające dla użytecznych, atrakcyjnych wizualnie przebiegów.
WebCodecs
W przypadku niektórych filmów miniatury ścieżek są przydatniejsze do nawigacji po osi czasu niż kształty fal. Generowanie miniatur wymaga jednak więcej zasobów niż generowanie przebiegów.
Nie możemy zapisać w pamięci podręcznej wszystkich możliwych miniatur podczas wczytywania, dlatego szybkie dekodowanie na osi czasu podczas przesuwania i powiększania jest kluczowe dla wydajności i szybkości działania aplikacji. Powodem, dla którego płynne wyświetlanie klatek nie jest możliwe, jest dekodowanie klatek. Do niedawna używaliśmy do tego odtwarzacza wideo HTML5. Takie podejście nie było niezawodne i często powodowało spowolnienie działania aplikacji podczas renderowania klatek.
Niedawno przeszliśmy na WebCodecs, które można używać w procesach webowych. Powinna ona poprawić naszą zdolność do rysowania miniatur dla dużej ilości warstw bez wpływu na wydajność głównego wątku. Implementacja web worker jest nadal w toku, ale poniżej przedstawiamy zarys obecnej implementacji głównego wątku.
Plik wideo zawiera wiele strumieni: wideo, audio, napisy itp., które są „zmuxowane”. Aby używać WebCodecs, najpierw musimy mieć demuxowany strumień wideo. Demuxowanie plików mp4 odbywa się za pomocą biblioteki mp4box, jak pokazano tutaj:
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);
}
};
Ten fragment kodu odnosi się do klasy demuxer
, która służy do zamykania interfejsu MP4Box
. Ponownie uzyskujemy dostęp do zasobu z IndexedDB. Te segmenty nie są koniecznie przechowywane w kolejności bajtów, a metoda appendBuffer
zwraca przesunięcie następnego fragmentu.
Oto jak dekodujemy klatkę wideo:
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 };
};
Struktura demuxera jest dość złożona i wykracza poza zakres tego artykułu. Każda klatka jest przechowywana w tablicy o nazwie samples
. Używamy demuxera, aby znaleźć najbliższy kluczowy klatkę obrazu poprzedzającą wybrany znacznik czasu, od którego rozpoczynamy dekodowanie filmu.
Filmy składają się z pełnych klatek, zwanych klatkami kluczowymi lub klatkami I, a także znacznie mniejszych klatek delta, często określanych jako klatki P lub B. Dekodowanie musi zawsze rozpoczynać się od klatki kluczowej.
Aplikacja dekoduje klatki, wykonując te czynności:
- Tworzenie instancji dekodera z wywołaniem zwrotnym wyjścia ramki.
- Konfigurowanie dekodera pod kątem konkretnego kodeka i rozdzielczości wejściowej.
- Tworzenie
encodedVideoChunk
na podstawie danych z demuxera. - wywołanie metody
decodeEncodedFrame
.
Robimy to, aż dotrzemy do klatki z pożądanym znacznikiem czasu.
Co dalej?
Skalowanie na stronie frontendu definiujemy jako zdolność do utrzymywania precyzyjnego i wydajnego odtwarzania w miarę zwiększania rozmiaru i zwiększania złożoności projektów. Jednym ze sposobów zwiększenia wydajności jest jednoczesne zamontowanie jak najmniejszej liczby filmów, ale w ten sposób narażamy się na ryzyko spowolnienia i niepłynności przejść. Opracowane przez nas wewnętrzne systemy przechowują w pamięci podręcznej komponenty wideo na potrzeby ponownego użycia, ale możliwości kontroli, jaką zapewniają tagi wideo HTML5, są ograniczone.
W przyszłości możemy spróbować odtwarzać wszystkie multimedia za pomocą WebCodecs. Dzięki temu możemy bardzo precyzyjnie określić, jakie dane buforować, co powinno pomóc w skalowaniu wydajności.
Możemy też lepiej odciążyć elementy web worker z ciężkimi obliczeniami na trackpadzie. Możemy też inteligentniej pobierać wstępnie pliki i generować ramki. Widzimy duże możliwości optymalizacji ogólnej wydajności aplikacji i rozszerzenia funkcjonalności za pomocą narzędzi takich jak WebGL.
Chcemy kontynuować inwestycje w TensorFlow.js, którego obecnie używamy do inteligentnego usuwania tła. Planujemy wykorzystać TensorFlow.js do innych zaawansowanych zadań, takich jak wykrywanie obiektów, wyodrębnianie cech, przenoszenie stylu itp.
Ostatecznie cieszymy się, że możemy dalej rozwijać nasz produkt, który zapewnia wydajność i funkcjonalność na poziomie aplikacji natywnych w bezpłatnej i otwartej sieci.