javascript

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

  • вторник, 21 июля 2026 г. в 00:00:12
https://habr.com/ru/articles/1060950/

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

Конвертация медиа. Люди решают эту задачу десятки раз в неделю. Видео с дрона нужно перевести в 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