javascript

Как мы рендерим видео на клиенте с помощью ffmpeg

  • среда, 4 февраля 2026 г. в 00:00:07
https://habr.com/ru/companies/dalee_group/articles/987020/

Обычно FFmpeg используют на сервере, но есть обертки и сборки для браузера, которые позволяют выполнять операции и на фронтенде. Сегодня речь пойдет о ffmpeg.wasm и настройке параметров для односекундной сборки видео, которое после просмотра пользователь может скачать. 

В статье покажем, как выглядит решение. Оно подойдет и для бэкенда, но нам пришлось обрабатывать и склеивать ролики именно на клиенте.

В Далее много специфичных проектов. На одном из таких клиент ограничивает доступ к серверной части своих платформ. По сути мы можем разворачивать только фронтенд.

Нужно было подготовить промо с видеорядом, которое показывает персональные результаты пользователя за определенный период. При этом должна быть возможность не только просмотра, но и скачивания. Сами ролики и тексты генерируются нейросетью.

Внутренняя логика — бэкенд предоставляет фронтенду массив ссылок на короткие видеофрагменты (ассеты), фоновую аудиодорожку, JSON с данными пользователя и текстами для наложения. Весь процесс — от загрузки до финального рендера — происходит на клиентской стороне. 

От 30 до 1 секунды рендеринга

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

Главная проблема — покадровая обработка: нет возможности склеивать видеофайлы без перекодирования. Процесс занимает время, прямо пропорциональное длительности итогового видео. Редкие пользователи готовы ждать десятки секунд до полной загрузки. Исключительные.

В поисках решения, которое позволило бы нам сделать склейку моментально, мы обратились к FFmpeg. Так у нас появилась возможность не только оптимизировать процесс, но и показать пользователю сначала превью видео — без перекодирования.

Итоговый сценарий:

  1. Сразу после авторизации в фоне запускалась полная генерация финального видео с текстом.

  2. Параллельно, чтобы пользователь не ждал, на фронтенде склеивались исходные видеофрагменты без текста и отображались в интерфейсе как превью.

  3. Текст и дизайн накладывались поверх видеоконтейнера средствами 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 делать долгое перекодирование.

Как еще можно использовать FFmpeg на фронтенде

Пока мы решали нашу задачу, то выделили еще несколько крутых функций библиотеки для своих digital-проектов. 

  1. Обрезка и конвертация форматов. Можно позволить пользователю подготовить контент перед загрузкой. Например, обрезать видео для аватарки и конвертировать в нужный формат.

  2. Генерация гифок из отрезка видео прямо в браузере.

  3. Автоматическое создание thumbnail'ов (обложек роликов) для видеогалереи.

  4. Потоковое преобразование (HLS/DASH). Для образовательных платформ или видеохостингов можно на фронтенде начать предобработку видео — перед загрузкой на сервер для дальнейшей адаптивной потоковой передачи.

FFmpeg хорошо подходит для коротких роликов, превью, персонализированных видео и промо-сценариев. Везде, где нужна локальная обработка и нет необходимости в настройке бэкенда. Для тяжелых, длинных или GPU-зависимых задач сервер по-прежнему остается более надежным выбором.

Приходилось ли вам оптимизировать скорость выдачи видеоконтента без бэкенда? Если был подобный опыт, то интересно узнать о ваших решениях и результатах. Увидимся в комментариях ;)