Обычно 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-зависимых задач сервер по-прежнему остается более надежным выбором.

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