Зачем вообще конвертировать файлы в браузере
Конвертация медиа. Люди решают эту задачу десятки раз в неделю. Видео с дрона нужно перевести в 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
R0bur
О, я, оказывается, необычный человек! Просто открываю командную строку и набираю: ffmpeg ...