FFmpeg в браузере: как мы сделали медиа-конвертер, который ни разу не обращается к серверу

от автора

Зачем вообще конвертировать файлы в браузере

Конвертация медиа. Люди решают эту задачу десятки раз в неделю. Видео с дрона нужно перевести в MP4 для мессенджера, HEIC с айфона в JPEG для сайта, аудиозапись интервью обрезать и сжать. Обычно это выглядит так: человек открывает один из сотен онлайн-конвертеров, загружает туда полугигабайтный файл, ждёт, пока он доползёт до сервера, потом ждёт очереди, потом скачивает обратно. Параллельно его файлы лежат на чужом сервере, о чём мало кто задумывается (а зря, если речь про корпоративный договор или личное видео).

Мы решили проверить, можно ли полностью убрать из этой схемы сервер. Чтобы конвертация происходила целиком в браузере, на устройстве пользователя. Без загрузок, без очередей, без серверных затрат. Так появился BrowsersKit: набор инструментов (медиа-конвертер, PDF, математика, Python-песочница), работающих на клиенте. Дальше расскажу, как это устроено внутри, какие грабли нас поджидали и как мы с ними справились.

Стек и архитектура: почему именно так

Vanilla JS и MPA вместо React/SPA

Первое решение, которое может показаться странным: никаких React, Vue, Svelte. Весь проект написан на чистом JavaScript (ESM-модули) со сборкой через Vite. Причина прозаична: у нас нет сложного реактивного состояния. Основная «тяжесть» тут в WASM-движках (FFmpeg, Pyodide), и тащить ради обёртки над ними 100+ КБ фреймворка не хотелось. Каждый лишний килобайт бандла замедляет First Contentful Paint, а для утилитарных сайтов это критично: человек пришёл с Google решить конкретную задачу, и если страница не загрузилась за секунду, он уже на сайте конкурента.

Архитектура: Multi-Page Application. Каждый инструмент (/pdf/split/, /media/video-to-gif/, /math/equations/) имеет свой index.html. Vite при сборке автоматически находит все index.html в проекте и делает из них отдельные точки входа Rollup:

function findHtmlEntries(dir = ROOT, acc = {}) {  for (const name of readdirSync(dir)) {    if (name.startsWith('.') || SKIP_DIRS.has(name)) continue;    const full = join(dir, name);    if (statSync(full).isDirectory()) {      findHtmlEntries(full, acc);    } else if (name === 'index.html') {      const rel = relative(ROOT, dir);      const key = rel === '' ? 'main' : rel.replace(/[\\/]/g, '-');      acc[key] = full;    }  }  return acc;}

Чтобы добавить новый инструмент, достаточно создать папку с index.html. Никаких правок в конфигах.

Слоёная изоляция

Кодовая база строго разделена на слои, импорты идут только сверху вниз:

[pages]  →  [ui]  →  [core / engines / utils]

Модули из core/, engines/, utils/ не имеют права трогать DOM. Ни document, ни window (за исключением детектирования фич). Вся визуальная часть живёт в ui/, а ядро остаётся чистыми функциями, которые тестируются в Node через Vitest без запуска браузера. Это не каприз: циклические импорты в MPA-сборке Rollup вызывают неопределённое поведение, от которого страдают все, кто делал «циклически связанные» React-компоненты в монорепозитории.

Технические детали: три кейса из боевого кода

Кейс 1. Watchdog: как обнаружить, что FFmpeg.wasm тихо умер

Главная проблема FFmpeg.wasm (@ffmpeg/core-mt, многопоточная сборка): дедлоки пула потоков. Emscripten создаёт ограниченный пул pthread’ов (примерно по числу navigator.hardwareConcurrency). Если декодер и энкодер одновременно запросят больше потоков, чем есть в пуле, pthread_create блокирует рантайм. Навсегда. Без ошибок, без логов, без вообще каких-либо внешних признаков. Пользователь видит застывший прогресс-бар. Вкладку можно только убить.

Мы написали watchdog (сторожевой таймер), который отслеживает «пульс» FFmpeg. Каждое лог-сообщение и каждое событие прогресса обновляет метку _lastActivity. Если FFmpeg молчит дольше 180 секунд подряд — это не «медленное кодирование», а зависание. Живой энкодер печатает статистику каждые доли секунды, даже на сложных кодеках.

const WATCHDOG_IDLE_MS = 180_000;async function execWatched(ff, args) {  _lastActivity = Date.now();  let timer;  const watchdog = new Promise((_, reject) => {    const tick = () => {      if (Date.now() - _lastActivity > WATCHDOG_IDLE_MS) {        reject(new Error(          'Обнаружено зависание FFmpeg (нет активности несколько минут). ' +          'Перезапускаю в надёжном режиме…'        ));        try { ff.terminate(); } catch (_) {}        return;      }      timer = setTimeout(tick, 10_000);    };    timer = setTimeout(tick, 10_000);  });  try {    return await Promise.race([ff.exec(args), watchdog]);  } finally {    clearTimeout(timer);  }}

Когда watchdog срабатывает, движок не просто перезапускается, а спускается по «лестнице надёжности». Идея в том, что если задача упала в обычном режиме, есть смысл попробовать снова, но с более консервативными настройками:

const MODES = [  { label: 'rt.status.ffmpeg.processing' },                        // обычный  { threads: '1', label: 'rt.status.ffmpeg.retry' },               // один поток  { threads: '1', downscale: true, label: 'rt.status.ffmpeg.lowmem' }, // + даунскейл  { variant: 'st', downscale: true, label: 'rt.status.ffmpeg.safe' }, // однопоточная сборка];

На последней ступеньке стоит однопоточная сборка ядра (core-st), в которой дедлок физически невозможен: в ней нет пула потоков вообще. Медленно, зато гарантированно доедет до конца.

Отдельная боль была с порогом watchdog’а. Первоначально стояла одна минута, и оказалось мало. Кодек HEVC (libx265) на слабом WASM-ядре реально может молчать больше минуты между строками статистики, не потому что завис, а потому что работает. Ложные срабатывания watchdog’а прерывали легитимные энкоды, мы поймали это только на E2E-тестах с реальными 4K-файлами. Подняли до 180 секунд — ложные срабатывания прекратились, а настоящие дедлоки всё ещё отлавливаются (при дедлоке нет вообще никакой активности, даже раз в минуту).

Кейс 2. Два пути монтирования файлов: MEMFS vs WORKERFS

WebAssembly компилируется под 32-битную архитектуру. Максимальный размер кучи: около 2 ГБ. Если пользователь загружает видеофайл весом 1.8 ГБ в MEMFS (стандартную файловую систему Emscripten, которая держит всё в оперативной памяти), а FFmpeg в процессе кодирования аллоцирует буферы — суммарно получается больше 2 ГБ, и рантайм падает с OOM (Out of Memory). На мобильных устройствах проблема ещё острее: браузер может убить вкладку при 500 МБ.

Решение: гибридный подход. Файлы до 128 МБ идут через MEMFS (быстрый многопоточный путь), файлы свыше отдаются WORKERFS, которая монтирует браузерный объект File как виртуальную файловую систему:

export const MEMFS_MAX_BYTES = 128 * 1024 * 1024;// ...внутри attemptJob:const useWorkerFS = file.size > MEMFS_MAX_BYTES;if (useWorkerFS) {  try {    await ff.createDir(MOUNT_DIR);    await ff.mount('WORKERFS', { files: [safe] }, MOUNT_DIR);    mounted = true;    inPath = `${MOUNT_DIR}/${inName}`;  } catch (mountErr) {    // Фолбэк: если WORKERFS недоступна, пишем в MEMFS и надеемся на лучшее    console.warn('[ffmpeg] WORKERFS недоступен, пишем в MEMFS:', mountErr);    await ff.writeFile(inName, new Uint8Array(await file.arrayBuffer()));    wrote = true;    inPath = inName;  }}

WORKERFS читает данные с диска порциями, не копируя файл целиком в кучу WASM. Это снимает проблему OOM для файлов любого размера. Но есть компромисс: WORKERFS работает через бридж с основным потоком браузера, поэтому кодирование ограничено одним потоком:

export function threadLimit(mode, hc, mounted) {  if (mounted) return '1';      // WORKERFS → всегда 1 поток  const cores = Math.max(1, hc || 4);  return mode === 'max'    ? String(Math.min(8, Math.max(1, cores - 1)))    : String(Math.min(4, Math.max(1, Math.floor(cores / 2))));}

Режим eco (по умолчанию) занимает половину ядер, чтобы пользователь мог продолжать сёрфить, пока конвертация идёт в фоне. Режим max берёт все ядра минус одно. WORKERFS: принудительно одно ядро, без вариантов.

Кейс 3. VP9 в WASM: когда кодек физически не работает

Это история про принятие неприятных инженерных решений. Пользователь выбирает «WebM · VP9» — популярный открытый формат для веба. Мы вызываем libvpx-vp9 через FFmpeg.wasm. И получаем:

  • TypeError в одних конфигурациях потоков,

  • вечное зависание в других,

  • крах ядра с RuntimeError: unreachable в третьих.

Причём любая комбинация аргументов (-threads 1, -row-mt 0, -tile-columns 0 -frame-parallel 0) не помогает. Даже на синтетическом 1-секундном клипе. Мы проверили: те же самые аргументы в нативном (десктопном) FFmpeg работают мгновенно. Это баг самой WASM-сборки libvpx, а не наших флагов.

Что делать? Обещание проекта: «довести задачу до конца». Мы приняли компромисс: программный путь FFmpeg для пункта меню «WebM · VP9» на самом деле кодирует в VP8. Тот же контейнер WebM, тот же кодек в паре с Opus, визуально пользователь разницы не заметит. Зато libvpx (VP8) в тех же условиях отрабатывает чисто.

case 'webm-vp9':  // libvpx-vp9 в этой emscripten-сборке нестабилен — экспериментально  // подтверждено, что ЛЮБАЯ комбинация аргументов либо роняет ядро,  // либо зависает навсегда. Программный путь кодирует в VP8.  // Быстрый аппаратный путь (WebCodecs) всё равно пробует настоящий VP9.  return [    '-c:v', 'libvpx',    ...(rate ? rate : ['-b:v', VP8_BR[q]]),    '-deadline', 'good',    '-cpu-used', '5',  ];

При этом быстрый аппаратный путь (через WebCodecs API) всё равно пробует настоящий VP9 первым — там он работает, потому что кодирование идёт нативно через GPU/CPU браузера, а не через WASM.

Аналогичная проблема с HEVC (libx265): этот кодек игнорирует -threads FFmpeg и создаёт собственные пулы потоков (WPP, frame-threads, lookahead). В Emscripten-сборке пул pthread’ов конечен, и лишний pthread_create блокирует рантайм навсегда. Решение: форсировать полностью однопоточный режим x265 через -x265-params pools=none:frame-threads=1. Медленнее, но завершается.

Двухколейная конвертация изображений

Для изображений мы реализовали гибридный роутинг. Если пользователь просто конвертирует JPG → WebP без дополнительных настроек (обрезка, поворот, фильтры), нет смысла поднимать 30-мегабайтный WASM-движок. Вместо этого работает быстрый путь через WebCodecs ImageDecoder + Canvas:

export async function convertImage(file, targetMime, quality) {  let bitmap;  if (ENV.imageDecoder) {    try {      const dec = new ImageDecoder({        data: await file.arrayBuffer(),        type: file.type || 'image/*',      });      const { image } = await dec.decode();      bitmap = image;    } catch (_) {      bitmap = await createImageBitmap(file);    }  } else {    bitmap = await createImageBitmap(file);  }  // ...рисуем на canvas, кодируем обратно через convertToBlob}

Аппаратное декодирование через WebCodecs, рендер на OffscreenCanvas, аппаратное кодирование обратно. Никакого WASM, никакого ожидания загрузки движка. Фотографию в 20 мегапикселей конвертирует за миллисекунды. FFmpeg подключается только тогда, когда нужен формат, который canvas не умеет (TIFF, BMP, ICO), или включены фильтры/обрезка.

Стратегия выбора элементарна:

export function chooseStrategy(category, formatId, opts) {  if (category === 'image' && image.canUseFastPath(formatId, opts)) {    return 'webcodecs';  }  return 'ffmpeg';}

Проблема загрузки тяжёлых ядер

WASM-ядро ffmpeg-core.wasm (многопоточная сборка) весит около 31 МБ. Бесплатный тариф Cloudflare Pages не даёт загружать файлы больше 25 МБ. Кажется, решение очевидно: нарезать файл на куски (*.part1, .part2, .part3) и склеить в браузере. Мы так и сделали. И сразу получили краши на мобильных устройствах.

Почему: при склейке в памяти одновременно висят три ArrayBuffer частей (~30 МБ), объединённый Uint8Array (~31 МБ), Blob из него (~31 МБ) и сам WASM-рантайм при инициализации (~60 МБ). Суммарно получается 150+ МБ одним махом. На iPhone или бюджетном Android это гарантированный OOM.

Финальное решение: нарезанные .part-файлы оставили только для локальной разработки и E2E-тестов, а на продакшене ядра грузятся цельным файлом напрямую с CDN (unpkg.com). URL версионирован, Service Worker кэширует его cache-first — после первого визита 64 МБ ядер не скачиваются заново:

export const VENDOR = {  mt: isProd    ? 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/umd'    : '/vendor/core-mt',  st: isProd    ? 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'    : '/vendor/core-st',};

Service Worker кэширует только неизменяемое: ядра с CDN и хешированные бандлы из /assets/. HTML сознательно не кэшируется, чтобы обновления сайта доезжали мгновенно:

function isCacheable(url) {  if (url.origin === self.location.origin) {    return url.pathname.startsWith('/vendor/') || url.pathname.startsWith('/assets/');  }  if (url.origin === 'https://unpkg.com') return url.pathname.startsWith('/@ffmpeg/');  if (url.origin === 'https://cdn.jsdelivr.net') return url.pathname.startsWith('/pyodide/');  return false;}

Чему научились

Браузерный WASM не серебряная пуля. Он позволяет делать невероятные вещи (полный FFmpeg в вашем браузере!), но приносит с собой целый класс проблем, которых нет в нативных приложениях: дедлоки пулов потоков, лимит памяти в 2 ГБ, нестабильность отдельных кодеков. Без watchdog’а и лестницы повторов проект был бы непригоден для реальных пользователей. Примерно каждая десятая тяжёлая задача зависала бы без диагностики.

WORKERFS: недооценённая фича Emscripten. Мы не нашли ни одной статьи, где бы кто-то использовал её для FFmpeg.wasm. Все примеры в интернете просто делают writeFile с arrayBuffer(), что гарантирует крах на файлах больше гигабайта. WORKERFS — единственный способ обрабатывать большие файлы без OOM, пусть и ценой однопоточности.

Не все кодеки рождены равными. libvpx-vp9 и libx265 в WASM-сборке ведут себя совсем не так, как их нативные аналоги. Приходится жертвовать «честностью» ради стабильности. Пользователю, который просто хочет конвертировать видео, не важно, VP9 там внутри или VP8 — важно, чтобы файл получился.

SEO для утилитарных сайтов: не маркетинг, а архитектура. Мы генерируем при сборке сотни страниц: 17 SEO-пар конвертации (/converter/jpg-to-png/, /converter/mp4-to-gif/…) × 12 языков = 200+ URL только для конвертера. Плюс инструменты для PDF, математики. Плюс sitemap.xml и robots.txt генерируются автоматически тем же Vite-плагином. Всё это делается на этапе vite build, без рантайм-рендеринга на сервере, потому что сервера у нас нет.

Вместо заключения

BrowsersKit: проект одного разработчика, который существует полтора месяца. Кода на 15 000 строк, покрытие юнит-тестами есть (Vitest), E2E гоняются в Docker через Playwright. Весь хостинг — бесплатный тариф Cloudflare Pages.

Главный вывод, к которому я пришёл за это время: браузерные технологии дозрели до того уровня, когда серверная обработка медиа для большинства пользовательских задач просто не нужна. SharedArrayBuffer, WebCodecs, WORKERFS, OffscreenCanvas — всё это уже работает в продакшене, если знать, где подложить соломку.

Надеюсь, описанные решения (особенно watchdog с лестницей повторов и гибридная файловая система) пригодятся тем, кто работает с WASM в браузере. Сам проект открыт для использования: browserskit.com

ссылка на оригинал статьи https://habr.com/ru/articles/1060950/