Як я запускав FFmpeg у браузері за допомогою WebAssembly

Сучасний світ неможливо уявити без медіаконтенту. Аватар, фото, відеоряд — усе це стало звичними атрибутами сучасності. Розробники вже за замовчуванням надають різні способи завантажити аватар або отримати його з соціальних мереж чи інших сервісів.

А що робити, коли вам вже недостатньо статичного зображення? Що, якщо ви хочете дати можливість створити щось схоже на відеовізитку. Коротке привітання, де ваш користувач розповість про себе чи свій продукт.

Здавалося б — чудово, завантажуємо файл і просто відтворюємо його. Так, але не зовсім. Адже користувач може завантажити не той формат стиснення або невірну орієнтацію. Може завантажити надто велике відео (до речі, у будь-якому випадку відеофайли будуть у рази більші, ніж можливості нормального сервера). А в ідеалі — ще й на стороні клієнта дати можливість подивитися, що ж вийде в результаті з цього відео.

І для таких операцій вам цілком підійде універсальний інструмент — FFmpeg.

Що каже качечка?

FFmpeg — це потужна кросплатформенна бібліотека та набір утиліт для роботи з мультимедіа. Вона дозволяє записувати, конвертувати, обробляти аудіо й відео, а також транслювати контент у реальному часі. Завдяки підтримці великої кількості форматів і кодеків FFmpeg став фактичним стандартом де-факто для обробки медіафайлів.

Прекрасно — будемо використовувати її на бекенді. На сервері це можна вирішити класичним шляхом, запустивши FFmpeg як зовнішній процес. Ось простий приклад на Kotlin:

val command = listOf(
"ffmpeg", "-y", // overwrite output files
"-i", inputFile.absolutePath,
"-vf", "scale=${width}:${height}",
"-c:v", "libvpx", // Use libvpx instead of libvpx-vp9 which might not be available
"-crf", "30",
"-b:v", "500k", // Set a specific bitrate instead of 0
outputFile.absolutePath
)
val process = ProcessBuilder(command)
.redirectErrorStream(true)
.start()

Тільки виходить, що файл потрібно спочатку завантажити на сервер, потім дочекатися черги обробки. Ніякої реактивності. Невже немає виходу???

І тут у справу втрутився випадок. На одній із співбесід ми торкнулися теми WebAssembly. Чи використовував я його на практиці (адже у мене були кейси роботи з воркерами на стороні клієнта). І я, під дощовою погодою, озброївся помічником ШІ та почав розбиратися. (Забігаючи наперед — я витратив 4 години, щоб зрозуміти підхід, і схоже, зможу застосовувати його у своїх проєктах).

Що каже качечка?

WebAssembly (Wasm) — це технологія, яка дозволяє запускати код, написаний мовами на кшталт C, C++ чи Rust, прямо у браузері з майже нативною швидкістю. Вона дає можливість використовувати складні й «важкі» бібліотеки, такі як FFmpeg, без інсталяції на комп’ютер і без необхідності піднімати сервер для обробки.

Тобто в мене є можливість завантажити ffmpeg на сторону клієнта й обробити відео там. Трохи пошуку — і я знаходжу приклади реалізації кейсу:
https://github.com/ffmpegwasm/ffmpeg.wasm/tree/main/apps/nextjs-app

Не все, що я хотів би бачити. Давайте трохи оптимізуємо приклад.

Додамо процес кешування на клієнті, адже бібліотека не маленька, і її краще підвантажити заздалегідь.

// Helper to safely access window.caches only in the browser.
function getCaches(): CacheStorage | null {
if (typeof window === "undefined") return null;
if (!("caches" in window)) return null;
return window.caches;
}

// Check if both JS and WASM are present in our cache bucket.
export async function isCoreCached(): Promise<boolean> {
const caches = getCaches();
if (!caches) return false;
const cache = await caches.open(FFMPEG_CACHE_NAME);
const [jsRes, wasmRes] = await Promise.all([
cache.match(CORE_JS_URL),
cache.match(CORE_WASM_URL),
]);
return !!(jsRes && wasmRes);
}

Ок, крок другий — загорнемо процесинг в окремий воркер і налаштуємо взаємодію з основним потоком. Нехай у нас передаються параметри обробки й повертається статус, скільки вже оброблено.

// Message handler from the main thread
self.onmessage = async (ev: MessageEvent<WorkerCommand>) => {
const msg = ev.data;
try {
if (msg.type === "load") {
await ensureLoaded(msg.payload?.coreURL, msg.payload?.wasmURL);
return;
}

if (msg.type === "preview") {
await ensureLoaded();
if (busy) throw new Error("FFmpeg worker is busy");
await doPreview(msg.payload);
return;
}

if (msg.type === "snapshot") {
await ensureLoaded();
if (busy) throw new Error("FFmpeg worker is busy");
await doSnapshot(msg.payload);
return;
}

if (msg.type === "cancel") {
cancelCurrent();
return;
}

if (msg.type === "terminate") {
cancelCurrent();
post({ type: "terminated" });
return;
}
} catch (e: any) {
post({ type: "error", payload: { message: e?.message || String(e) } });
}
};

І ось — вуаля — перед вами реалізація обробки відеоаватарки на стороні клієнта. Користувач знімає себе на камеру, потім обробляє файл, наприклад, вирізавши й стиснувши перші 10 секунд і створивши прев’юшку для цього відео.

Я розумію, що таким чином ми навантажуємо ресурси клієнта, і що потрібно буде ще багато чого перевірити — як процес почувається у різних браузерах і системах. Виміряти навантаження. Але сама мета, яку я поставив собі — подивитися, чи працює кейс взагалі. І я отримав відповідь — так, це можливо.

GitHub: https://github.com/ninydev-com/ffmpeg-minio-solutions/tree/main/nextjs