Как мы рендерим видео на клиенте с помощью ffmpeg
- среда, 4 февраля 2026 г. в 00:00:07
Обычно FFmpeg используют на сервере, но есть обертки и сборки для браузера, которые позволяют выполнять операции и на фронтенде. Сегодня речь пойдет о ffmpeg.wasm и настройке параметров для односекундной сборки видео, которое после просмотра пользователь может скачать.
В статье покажем, как выглядит решение. Оно подойдет и для бэкенда, но нам пришлось обрабатывать и склеивать ролики именно на клиенте.

В Далее много специфичных проектов. На одном из таких клиент ограничивает доступ к серверной части своих платформ. По сути мы можем разворачивать только фронтенд.
Нужно было подготовить промо с видеорядом, которое показывает персональные результаты пользователя за определенный период. При этом должна быть возможность не только просмотра, но и скачивания. Сами ролики и тексты генерируются нейросетью.
Внутренняя логика — бэкенд предоставляет фронтенду массив ссылок на короткие видеофрагменты (ассеты), фоновую аудиодорожку, JSON с данными пользователя и текстами для наложения. Весь процесс — от загрузки до финального рендера — происходит на клиентской стороне.
Для реализации в первую очередь рассмотрели связку Canvas и MediaRecorder API. Выглядело это так: каждый видеофрагмент поочередно воспроизводился в скрытом элементе <video>, его кадры отрисовывались на Canvas, поверх добавлялся текст. Результат записывался через MediaRecorder.
Главная проблема — покадровая обработка: нет возможности склеивать видеофайлы без перекодирования. Процесс занимает время, прямо пропорциональное длительности итогового видео. Редкие пользователи готовы ждать десятки секунд до полной загрузки. Исключительные.

В поисках решения, которое позволило бы нам сделать склейку моментально, мы обратились к FFmpeg. Так у нас появилась возможность не только оптимизировать процесс, но и показать пользователю сначала превью видео — без перекодирования.
Сразу после авторизации в фоне запускалась полная генерация финального видео с текстом.
Параллельно, чтобы пользователь не ждал, на фронтенде склеивались исходные видеофрагменты без текста и отображались в интерфейсе как превью.
Текст и дизайн накладывались поверх видеоконтейнера средствами HTML/CSS.
Когда человек доходил до кнопки скачивания, фоновое видео уже было готово. Если нет — показывался индикатор загрузки.
Быстрое превью (конкатенация) — отказываемся от рендера кадр-в-кадр на Canvas и задаем в параметрах -c:v copy, чтобы скопировать видеопоток без перекодирования.
Результат: < 1 секунды.
ℹ️ -c:v copy — «волшебная» команда для быстрой склейки одинаковых по кодеку видео. Для правильного выполнения нужны исходники, сбалансированные по размеру и битрейту.
Метод склейки отрывков видео без добавления текста:
async function mergeVideos(videoUrls, audioUrl) { const ffmpeg = new FFmpeg(); console.time('ffmpeg'); // Загрузка core const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.2/dist/umd'; await ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), }); try { // Загрузка видео for (let i = 0; i < videoUrls.length; i++) { const response = await fetch(videoUrls[i]); const buffer = await response.arrayBuffer(); await ffmpeg.writeFile(`input${i}.mp4`, new Uint8Array(buffer)); } // Загрузка аудио const audioResponse = await fetch(audioUrl); const audioBuffer = await audioResponse.arrayBuffer(); await ffmpeg.writeFile('audio.m4a', new Uint8Array(audioBuffer)); // Создание списка для конкатенации const concatList = Array.from({length: videoUrls.length}, (_, i) => `file 'input${i}.mp4'`).join('\n'); await ffmpeg.writeFile('input.txt', new TextEncoder().encode(concatList)); // Выполнение команды await ffmpeg.exec([ '-f', 'concat', '-safe', '0', '-i', 'input.txt', '-i', 'audio.m4a', '-c:v', 'copy', '-c:a', 'aac', '-shortest', 'output.mp4' ]); // Чтение результата const data = await ffmpeg.readFile('output.mp4'); return new Blob([data], { type: 'video/mp4' }); } catch (error) { console.error('Error:', error); throw error; } finally { console.timeEnd('ffmpeg'); } }
Финальный рендер (полная обработка на фоне) — для скачивания делаем разовую, но оптимизированную генерацию с текстом.
Результат равен длине видео: ~30 секунд.
Метод генерации итогового видеоряда с текстом и аудио:
async function mergeVideos(videoUrls: string[], audioUrl: string, texts: string[]) { console.time("mergeVideos"); const videoEls = videoUrls.map(url => { const v = document.createElement("video"); v.src = url; v.crossOrigin = "anonymous"; v.muted = true; return v; }); await Promise.all(videoEls.map(v => new Promise<void>(resolve => { v.oncanplaythrough = () => resolve(); v.load(); }))); const scale = 1; const width = videoEls[0].videoWidth * scale; const height = videoEls[0].videoHeight * scale; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d")!; const canvasStream = canvas.captureStream(22); const audioCtx = new AudioContext(); const response = await fetch(audioUrl); const buffer = await response.arrayBuffer(); const decoded = await audioCtx.decodeAudioData(buffer); const source = audioCtx.createBufferSource(); source.buffer = decoded; const dest = audioCtx.createMediaStreamDestination(); source.connect(dest); source.start(); const mixedStream = new MediaStream([ ...canvasStream.getTracks(), ...dest.stream.getTracks(), ]); const recorder = new MediaRecorder(mixedStream, { mimeType: "video/webm" }); const chunks: BlobPart[] = []; recorder.ondataavailable = e => chunks.push(e.data); recorder.start(); let current = 0; const drawNextVideo = () => { if (current >= videoEls.length) { recorder.stop(); return; } const v = videoEls[current]; v.onended = () => { current++; drawNextVideo(); }; v.play(); const loop = () => { if (!v.paused && !v.ended) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(v, 0, 0, width, height); ctx.fillStyle = "white"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; let fontSize = 60 * scale; ctx.font = `bold ${fontSize}px sans-serif`; const text = texts[current] || ""; const maxWidth = width * 0.8; while (ctx.measureText(text).width > maxWidth && fontSize > 10) { fontSize -= 2; ctx.font = `bold ${fontSize}px sans-serif`; } const y = height / 2 + height * 0.25; ctx.fillText(text, width / 2, y); requestAnimationFrame(loop); } }; loop(); }; drawNextVideo(); return new Promise<Blob>(resolve => { recorder.onstop = () => { const blob = new Blob(chunks, { type: "video/webm" }); downloadBlob(blob, "merged-video.webm"); console.timeEnd("mergeVideos"); resolve(blob); }; }); }
Остальная производительность уже зависит от мощности конкретного браузера, настроек окружения клиента и устройства — CPU и памяти. Учитывая это, стоит позаботится о пользователе и добавить прогресс-бар.
Обратите внимание:
Метод -c:v copy быстр, но не позволяет применять видеофильтры/текст к видеопотоку, потому что тогда нужно декодировать и кодировать заново.
Для финального рендера придется идти на компромисс между скоростью кодирования и качеством выходного файла.
Исходные видео должны быть оптимизированы: один кодек, разрешение, частота кадров. Разный FPS или разрешение заставят FFmpeg делать долгое перекодирование.
Пока мы решали нашу задачу, то выделили еще несколько крутых функций библиотеки для своих digital-проектов.
Обрезка и конвертация форматов. Можно позволить пользователю подготовить контент перед загрузкой. Например, обрезать видео для аватарки и конвертировать в нужный формат.
Генерация гифок из отрезка видео прямо в браузере.
Автоматическое создание thumbnail'ов (обложек роликов) для видеогалереи.
Потоковое преобразование (HLS/DASH). Для образовательных платформ или видеохостингов можно на фронтенде начать предобработку видео — перед загрузкой на сервер для дальнейшей адаптивной потоковой передачи.
FFmpeg хорошо подходит для коротких роликов, превью, персонализированных видео и промо-сценариев. Везде, где нужна локальная обработка и нет необходимости в настройке бэкенда. Для тяжелых, длинных или GPU-зависимых задач сервер по-прежнему остается более надежным выбором.
Приходилось ли вам оптимизировать скорость выдачи видеоконтента без бэкенда? Если был подобный опыт, то интересно узнать о ваших решениях и результатах. Увидимся в комментариях ;)