Недавно перед нами встала задача быстро реализовать микросервис для конвертации видео. Стандартным решением для таких целей является 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

Это позволяет нам:

  1. Скачивать видео чанками и сразу передавать в FFmpeg

  2. Получать результат из 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

Как это работает на практике

  1. __aenter__ запускает процесс FFmpeg с командой, которая выводит результат в pipe:1. В FFmpeg pipe:1 — это специальное обозначение stdout (File Descriptor 1), означающее «выведи в stdout». Это позволяет перенаправить выходной поток FFmpeg в наш процесс, откуда мы можем читать его по частям.

  2. convert() — асинхронный генератор. Он читает stdout FFmpeg по 8 КБ и отдаёт чанки.

  3. __aexit__ завершает процесс. Помимо этого, он также обрабатывает ошибки.

Данные не хранятся в памяти в виде полного файла. FFmpeg читает видеофайл с URL чанками и постепенно пишет в stdout, где мы читаем его чанками и сразу передаём дальше. Получился механизм, похожий на Backpressure: если в Python-процессе мы станем читать stdout медленнее, то FFmpeg будет блокироваться на запись, что приведёт к его замедлению.

Пресеты: что мы конвертируем

Путём долгих экспериментов с различными видео и настройками я сделал пять пресетов. Идея с пресетами, на мой взгляд, удобна: их можно хранить в базе данных, создавать неограниченное количество и выполнять множество конвертаций одного видео (например, для разных платформ, устройств, разрешений и т.д.).

Пресет

Разрешение

Битрейт

CRF

Preset

optimal

1920×1080

2500k

23

fast

high_quality

1920×1080

4000k

18

slow

fast_convert

1280×720

1500k

26

ultrafast

mobile

854×480

800k

25

fast

mobile_low_size

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

Заключение

Потоковая конвертация видео — это не просто «крутая фича», а практическая необходимость для сервисов с большими файлами.

  1. Работоспособность потоковой конвертации: FFmpeg действительно умеет читать чанками по HTTP Range и писать чанками в stdout, и это можно использовать

  2. Низкое потребление памяти вне зависимости от размера входного файла (в сценариях, которые мы используем)

  3. Нет временных файлов, что позволяет экономить дисковое пространство

Примечание

По ряду причин код в статье не полностью соответствует проду, здесь концептуально передана суть. Настоящая реализация может отличаться в деталях, но общая архитектура и принципы те же.


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

P.S. Надеюсь, в дальнейшем у меня появится больше материалов для публикаций, когда сервис «поживёт» в эксплуатации и накопит интересные случаи.