Недавно перед нами встала задача быстро реализовать микросервис для конвертации видео. Стандартным решением для таких целей является FFmpeg, который умеет читать файлы чанками (запросы HTTP Range) с URL и выводить результат в stdout. Поэтому я решил попробовать подход с потоковой конвертацией. Важно уточнить, что под «потоковой обработкой» здесь подразумевается передача данных в виде последовательности чанков (Chunked Streaming), а не классический Continuous Streaming, как в случае с live-видео.
Такой подход обладает рядом преимуществ:
Не требуется дисковое пространство (при большом количестве видео это существенно снижает стоимость инфраструктуры)
Нет нужды загружать весь файл в оперативную память (в большинстве случаев)
На пике загрузки не получаются одновременно две полные копии файла
Нет необходимости контролировать временные файлы на диске (в случае сбоев не требуется их очистка)
Архитектура: как всё устроено
Проект Transcode Backend — это микросервис на Python, предназначенный для потоковой конвертации видео с использованием FFmpeg. Он принимает запросы на конвертацию видео (сообщение из очереди), асинхронно их обрабатывает и публикует сообщение в очередь с URN результирующего видео.
FS Proxy: откуда берутся файлы
Прежде чем говорить про конвертацию, важно понять, откуда берутся файлы и куда уходят сконвертированные видео.
У нас есть сервис FS Proxy — абстрактное файловое хранилище с HTTP API. Оно поддерживает стандартные запросы HTTP Range (это важно). Это означает, что мы можем скачивать видео частями и загружать результирующее видео тоже частями.
# Пример запроса с Range GET /srv/v1/files/{urn}/download Range: bytes=0-1048575 HTTP/1.1 206 Partial Content Content-Range: bytes 0-1048575/32925286
Это позволяет нам:
Скачивать видео чанками и сразу передавать в FFmpeg
Получать результат из FFmpeg и сразу загружать его (так же чанками) обратно в FS Proxy
Главный герой статьи — VideoConverter
Начинается самое интересное: класс VideoConverter осуществляет конвертацию видео и на выходе отдаёт AsyncIterable[bytes]. Вот его концептуальная архитектура:
class VideoConverter: def __init__( self, input_video_info: InputVideoInfo, video_conversion_preset: VideoConversionPreset, ffmpeg_path: str = 'ffmpeg', ): self._ffmpeg_path = ffmpeg_path self._input_video_info = input_video_info self._video_conversion_preset = video_conversion_preset self._proc: Process | None = None async def __aenter__(self) -> Self: cmd = self._video_conversion_preset.render_cmd( headers=self._input_video_info.headers, input_url=self._input_video_info.url, ffmpeg_path=self._ffmpeg_path, ) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.DEVNULL, ) self._proc = proc return self async def convert(self) -> AsyncIterable[bytes]: if self._proc is None: raise RuntimeError('Unreachable') if self._proc.stdout is None: raise RuntimeError('Unreachable') while True: chunk = await self._proc.stdout.read(8192) if not chunk: break yield chunk async def __aexit__( self, exc_type: type[BaseException] | None = None, exc_val: BaseException | None = None, exc_tb: TracebackType | None = None, ) -> bool: # Обрабатываем ошибки, останавливаем процесс ffmpeg, если он сам не остановился pass
Как это работает на практике
__aenter__запускает процесс FFmpeg с командой, которая выводит результат вpipe:1. В FFmpegpipe:1— это специальное обозначение stdout (File Descriptor 1), означающее «выведи в stdout». Это позволяет перенаправить выходной поток FFmpeg в наш процесс, откуда мы можем читать его по частям.convert()— асинхронный генератор. Он читает stdout FFmpeg по 8 КБ и отдаёт чанки.__aexit__завершает процесс. Помимо этого, он также обрабатывает ошибки.
Данные не хранятся в памяти в виде полного файла. FFmpeg читает видеофайл с URL чанками и постепенно пишет в stdout, где мы читаем его чанками и сразу передаём дальше. Получился механизм, похожий на Backpressure: если в Python-процессе мы станем читать stdout медленнее, то FFmpeg будет блокироваться на запись, что приведёт к его замедлению.
Пресеты: что мы конвертируем
Путём долгих экспериментов с различными видео и настройками я сделал пять пресетов. Идея с пресетами, на мой взгляд, удобна: их можно хранить в базе данных, создавать неограниченное количество и выполнять множество конвертаций одного видео (например, для разных платформ, устройств, разрешений и т.д.).
Пресет | Разрешение | Битрейт | CRF | Preset |
|---|---|---|---|---|
| 1920×1080 | 2500k | 23 | fast |
| 1920×1080 | 4000k | 18 | slow |
| 1280×720 | 1500k | 26 | ultrafast |
| 854×480 | 800k | 25 | fast |
| 854×480 | 800k | 30 | slow |
CRF (Constant Rate Factor) — параметр качества в H.264. Чем меньше значение, тем выше качество:
18 — визуально близко к исходному качеству (в большинстве сценариев)
23 — стандартное качество
28+ — агрессивное сжатие
Генерирование команды FFmpeg
Каждый пресет генерирует свою команду. Это выглядит примерно так:
def render_cmd( self, *, headers: dict[str, str], input_url: str, ffmpeg_path: str = 'ffmpeg', ) -> list[str]: cmd = [ ffmpeg_path, '-timeout', str(self.timeout), '-threads', str(self.threads), '-headers', self._build_headers_string(headers), '-i', str(input_url), '-vf', f"scale='if(gt(iw,ih),min({self.max_resolution.width},iw),-2)':" f"'if(gt(iw,ih),-2,min({self.max_resolution.height},ih))'", '-c:v', 'libx264', '-preset', str(self.preset), '-crf', str(self.crf), '-b:v', str(self.video_bitrate), '-profile:v', str(self.profile), '-level', str(self.level), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', str(self.audio_bitrate), '-ac', '2', '-ar', '48000', '-movflags', 'frag_keyframe+empty_moov+faststart', '-f', str(self.output_format), 'pipe:1', # stdout ] return cmd
Разбор важных параметров
-pix_fmt yuv420p — пиксельный формат. Фактически является стандартом для совместимости с HTML5 <video> в браузерах. Если исходное видео имеет другой пиксельный формат (например, yuv444p), то FFmpeg конвертирует цветовое пространство, что может незначительно повлиять на качество.
-movflags frag_keyframe+empty_moov+faststart — флаги для стриминга:
frag_keyframe: создаёт фрагментированный MP4, позволяющий начать воспроизведение до полной загрузкиempty_moov: позволяет начинать запись без предварительного создания moov-атома (важно для pipe)faststart: оптимизирует расположение метаданных для прогрессивного воспроизведения (где это применимо)
Можно использовать обёртки вроде FFmpeg-Python, но поскольку они не дают такого тонкого контроля над потоками данных, то потоковый конвертер не получился бы.
UseCase: VideoConverterUploader
Высокоуровневый компонент, который связывает всё вместе:
class VideoConverterUploader(BaseUseCase): async def execute( self, urn: FileURN, video_conversion_preset: VideoConversionPreset, ) -> FileURN: async with VideoConverter( input_video_info=InputVideoInfo( url=self._build_download_video_url(urn), headers=self._config.fs_auth_headers, ), video_conversion_preset=video_conversion_preset, ) as converter: output = await self._file_uploader.execute( file_name=self._build_file_name(urn), file_mimetype=self._build_file_mimetype(video_conversion_preset), file_stream=converter.convert(), # AsyncIterable[bytes] ) return output.urn
Здесь converter.convert() возвращает AsyncIterable[bytes], который напрямую передаётся в file_uploader. Нет буферизации, нет временных файлов.
Эксперименты: результаты
Для оценки производительности я провёл серию экспериментов с тремя видеофайлами:
Видео | Разрешение | Длительность | Размер |
|---|---|---|---|
Video 1 | 1080×1920 | 21.3s | 31.4MB |
Video 2 | 1080×1920 | 31.2s | 71.6MB |
Video 3 | 3840×2160 (4K) | 59.5s | 328.0MB |
Каждое видео было конвертировано с использованием пяти пресетов, в итоге получилось 15 записей. Эксперименты проводились в поде с request=limits: 1 CPU, 1 GB. Каждый эксперимент повторялся трижды. Разброс составлял менее 2%, поэтому результаты были усреднены.
Длительность конвертации

Как и ожидалось, ultrafast обеспечивает минимальное время обработки, но даёт максимальный размер файла. slow, наоборот, требует больше времени на конвертацию, но обеспечивает лучшее сжатие.
Интересный момент: для видео в разрешении 4K разница между пресетами становится более выраженной: процессор работает интенсивнее.
Пиковое потребление памяти

Преимущества потоковой конвертации заключаются в следующем:
В проведённых экспериментах Python-процесс потребляет ~130 МБ и слабо зависит от размера входного видео
FFmpeg потребляет от 139 до 531 МБ в зависимости от пресета и видео
Нет скачков памяти при обработке больших файлов
Для сравнения я выполнил конвертацию video 3 обычным способом (с пресетом high_quality). Пиковое потребление памяти составило 772 МБ. Остальные метрики при этом совпали в рамках погрешности, а результирующее видео оказалось идентичным.
Вывод: при классическом подходе пиковое потребление памяти FFmpeg оказалось на 45% выше по сравнению с потоковой конвертацией. К этому следует добавить необходимость хранения временных файлов.
Размер результирующего видео

Парадоксально, но пресет mobile_low_size с CRF=30, обеспечивающий наилучшее сжатие, конвертируется дольше из-за использования slow preset.
Коэффициент сжатия

Наилучшее сжатие (~10x) достигается с пресетом mobile_low_size для вертикальных видео. Для горизонтального 4K-видео коэффициент сжатия ниже (~5x), поскольку исходник уже хорошо оптимизирован.
Асинхронная обработка
Конвертер работает асинхронно: получил сообщение из очереди — сконвертировал — отправил сообщение с URN результирующего файла. Такой подход позволяет:
Параллельно обрабатывать множество задач
Обеспечить отказоустойчивость (задачи не теряются при перезапуске)
Масштабировать через добавление воркеров
Лицензионные аспекты: GPL и LGPL
FFmpeg распространяется под лицензией GPL (General Public License). Однако есть возможность собрать его в урезанной версии под лицензией LGPL (Lesser GPL).
Существует два легальных варианта использования в коммерческих проектах (где нельзя раскрывать ваш исходный код).
Вариант 1: вызов внешнего процесса (самый простой)
Если вы запускаете FFmpeg как внешний процесс через subprocess (или os.system, subprocess.run и т.д.), вы не связываетесь с его библиотеками на уровне кода. Вы просто используете программу так, как если бы пользователь вызывал её из командной строки. Этот подход легален и не накладывает обязательств по раскрытию исходного кода вашего приложения.
Ограничение: в этом случае, по-моему, нельзя включать бинарь FFmpeg в свой Docker-образ или дистрибутив приложения.
Вариант 2: Сборка FFmpeg с флагом --enable-lgpl
Если вам нужно добавить в свой проект FFmpeg (например, в Docker-образ), рекомендую собрать его самостоятельно, используя флаг --enable-lgpl (есть в документации FFmpeg). Это гарантирует, что все используемые компоненты будут под лицензией LGPL, что позволяет легально использовать FFmpeg без необходимости раскрывать исходный код вашего приложения.
Заключение
Потоковая конвертация видео — это не просто «крутая фича», а практическая необходимость для сервисов с большими файлами.
Работоспособность потоковой конвертации: FFmpeg действительно умеет читать чанками по HTTP Range и писать чанками в stdout, и это можно использовать
Низкое потребление памяти вне зависимости от размера входного файла (в сценариях, которые мы используем)
Нет временных файлов, что позволяет экономить дисковое пространство
Примечание
По ряду причин код в статье не полностью соответствует проду, здесь концептуально передана суть. Настоящая реализация может отличаться в деталях, но общая архитектура и принципы те же.
Если у вас есть вопросы или предложения — пишите в комментариях! Также буду рад узнать, как вы решаете задачу конвертации видео в своих проектах.
P.S. Надеюсь, в дальнейшем у меня появится больше материалов для публикаций, когда сервис «поживёт» в эксплуатации и накопит интересные случаи.