Недавно перед нами встала задача быстро реализовать микросервис для конвертации видео. Стандартным решением для таких целей является FFmpeg, который умеет читать файлы чанками (запросы HTTP Range) с URL и выводить результат в stdout. Поэтому я решил попробовать подход с потоковой конвертацией.Важно уточнить, что под «потоковой обработкой» здесь подразумевается передача данных в виде последовательности чанков (Chunked Streaming), а не классический Continuous Streaming, как в случае с live-видео.
Недавно перед нами встала задача быстро реализовать микросервис для конвертации видео. Стандартным решением для таких целей является 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 осуществляет конвертацию видео и на выходе отдаёт 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. В FFmpeg pipe: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+ — агрессивное сжатие
Каждый пресет генерирует свою команду. Это выглядит примерно так:
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, но поскольку они не дают такого тонкого контроля над потоками данных, то потоковый конвертер не получился бы.
Высокоуровневый компонент, который связывает всё вместе:
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 результирующего файла. Такой подход позволяет:
Параллельно обрабатывать множество задач
Обеспечить отказоустойчивость (задачи не теряются при перезапуске)
Масштабировать через добавление воркеров
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. Надеюсь, в дальнейшем у меня появится больше материалов для публикаций, когда сервис «поживёт» в эксплуатации и накопит интересные случаи.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Shorts Maker v2: как я превратил AI-генератор видео в возобновляемый production pipeline на Python | 0 | 10.3 | 03-08-2026 |
| 2 | «Спрятать и не потерять»: Реализуем DSSS-стеганографию в MP3 на Python. AES-256 против сжатия с потерями | 0 | 8.42 | 07-02-2026 |
| 3 | aiohttp - 3.14.3 | 0 | 17.69 | 23-07-2026 |
| 4 | Как я переводы видео автоматизировать собирался | 0 | 7.8 | 19-03-2026 |
| 5 | aiohttp - 3.14.0 | 0 | 17.69 | 01-06-2026 |
| 6 | aiohttp - 3.14.1 | 0 | 17.69 | 07-06-2026 |
| 7 | Как мы укротили сложный процесс с помощью CQRS и стейт-машин | 0 | 9.13 | 02-06-2026 |
| 8 | Современный MQTT-сервис на Python | 0 | 11.11 | 01-07-2026 |
| 9 | Реляционные шарады: превращаем фильмы в таблицы | 0 | 7.23 | 12-01-2026 |
| 10 | vit - Git for Video Editing | 0 | 10 | 18-05-2026 |