Всем привет! Меня зовут Павлов Денис, я .NET backend разработчик в компании DD Planet. 

В статье расскажу о реализации загрузки и обработки видеофайлов с использованием Minio в качестве хранилища и FFmpeg для обработки видео.

Предпосылки реализации видео или боль пользователей

Мы работаем над проектом, который успел пройти  путь от стартапа до большой экосистемы со своими направлениями в разных сферах. Одно из таких направлений — social. Первой крупной фичей были чаты, позволяющие общаться не только P2P, но и со своими соседями, а позднее создавать собственные чаты с людьми со всей России. Второй функцией стали сообщества, начиная от домовых и заканчивая тематическими, объединяющими людей с общими интересами. И третья составляющая этого направления — посты в ленте, которые представляли собой различные новости, объявления по поиску специалистов или потерянных вещей, а также смешные публикации с котиками.

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

Готовые интеграционные решения

Мы сформулировали основные требования, которым должно было соответствовать будущее решение:

  • Загрузка видео чанками с последующим сохранением итогового видео на сервере;

  • Гибкая настройка генерации различных разрешений видео (240p, 360p и т.д.);

  • Генерация превью изображения и размытия для фона;

  • Мониторинг процесса загрузки видео и его обработки для отображения общего прогресса пользователю.

После анализа ряда готовых решений мы остановились на двух вариантах: Api.video и Cloudinary.

Api.video предоставляет полностью настраиваемый API для управления всеми аспектами работы с видео, включая кодирование и доставку. Платформа поддерживает широкий диапазон разрешений — от 360p до 4K, при этом не взимает дополнительной платы за кодирование. Пользователи могут управлять несколькими API-ключами в безопасной и централизованной среде. Решение подходит как для стриминга в реальном времени, так и для воспроизведения видео по запросу. Примерная стоимость использования составляет 33$ в месяц за хранение 1000 минут видео и предоставление 15000 минут просмотров.

Особенности Api.video:

  • Полная настройка API: можно настроить все аспекты работы с видео, начиная от кодирования и заканчивая доставкой;

  • Простая интеграция: видео и прямые трансляции можно добавить в любой раздел сайта или приложения всего за несколько минут;

  • Загрузка видео пользователями: конечные пользователи могут самостоятельно загружать видео и начинать прямые трансляции;

  • Поддержка различных видов контента: платформа подходит для создания онлайн-обучения, коротких видео, площадок электронной коммерции и многого другого;

  • Надежность и безопасность: Api.video защищает приложение от несанкционированного доступа и поддерживает высокое качество потоковой передачи.

Cloudinary предлагает набор API, включающий видеоплеер, кодирование и хранение, CDN и видеоаналитику. Он также предоставляет SDK для популярных фреймворков, встроенный CDN класса “предприятие” и предварительно построенные интеграции с популярными приложениями, такими как CMS и системы электронной коммерции. Cloudinary поддерживает все современные форматы видео и кодеки, что упрощает процесс интеграции видеопроигрывателей в веб-сайты и мобильные приложения.

Основные возможности Cloudinary:

  • Автоматическая оптимизация: автоматически оптимизирует медиафайлы для различных устройств и сетевых условий, улучшая скорость загрузки и качество изображений и видео;

  • Резиновое масштабирование: позволяет масштабировать изображения и видео без потери качества;

  • Управление медиаконтентом: предлагает инструменты для управления медиаконтентом, включая загрузку, категоризацию, поиск и управление версиями медиафайлов;

  • Доставка контента: обеспечивает быструю и надежную доставку медиаконтента через глобальные CDN;

  • Интеграция с другими сервисами: может быть интегрирован с популярными платформами и CMS, включая WordPress, Shopify, Magento, Adobe Experience Manager, благодаря поддержке API и SDK.

Cloudinary предлагает бесплатный план, позволяющий протестировать основные функции платформы без затрат. Этот план имеет ограничения по объему хранения, трафику и другим ресурсам, но идеально подходит для знакомства с возможностями сервиса. Стоимость тарифов начинается от $89 в месяц.

Приведем краткий список возможностей, которые предоставляют платные версии:

  • Резервное копирование в собственную корзину S3;

  • Поиск на основе автоматической маркировки;

  • Доступ к бесплатным и платным дополнениям;

  • Ускоренная поддержка;

  • Доступ к активам из разрешенного/запрещенного списка;

  • Увеличение пропускной способности видео 2:1;

  • Многопользовательское администрирование на основе ролей;

  • Поддержка индивидуального домена (CNAME);

  • Дополнительный сертификат HTTPS SSL;

  • Параметры аутентификации.

Однако, после долгих раздумий, мы взялись за разработку собственного решения по нескольким причинам:

Первое: оба инструмента предлагают возможность загрузки больших видео файлов чанками, тогда как для сохранения видео в нашем хранилище необходимо либо предварительно склеивать чанки и сохранять итоговое видео на сервере, а затем отправлять его для дополнительной обработки, либо загружать чанки напрямую в Api.video и после делать дополнительный запрос для скачивания полученного видео. В любом случае мы получаем лишние действия, которых необходимо избегать для оптимизации работы.

Второй причиной стало отсутствие мониторинга прогресса обработки видео. Пользователь сможет отслеживать лишь прогресс загрузки на сервер, но в случае возникновения ошибок во время обработки он не сможет узнать об этом и будет напрасно ждать завершения процесса.

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

Знакомство с FFmpeg. Проблемы, лицензии и использование

Для работы с видео мы решили использовать библиотеку FFmpeg. Кратко пробежимся по особенностям ее использования.

FFmpeg — это кроссплатформенное решение для обработки, сжатия, редактирования видео, которое объединяет в себе более 300 видео/аудио/графических кодеков, декодеров, муксеров, демуксеров и различных фильтров. Оно выступает ядром для почти всех современных инструментов для обработки видео. Это решение позволяет не погружаться в видеообработку, а написать прослойку для работы с FFmpeg и использовать готовые методы. Кроме того, взаимодействовать с FFmpeg можно напрямую, используя обычную консоль.

FFmpeg имеет два вида лицензии: LGPL и GPL.

Разница между лицензиями GNU Lesser General Public License (LGPL) и GNU General Public License (GPL) заключается в том, как каждая из них применяется к программному обеспечению, которое их включает. В контексте FFmpeg эта разница имеет значение для разработчиков, которые хотят интегрировать его в свои проекты.

GNU Lesser General Public License (LGPL)

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

GNU General Public License (GPL)

  • GPL, в отличие от LGPL, требует, чтобы все программное обеспечение, включая библиотеки, было свободно распространяемым и модифицируемым. Если в проекте используется часть FFmpeg, лицензированная под GPL (например, кодеки x264 и x26), весь проект должен быть лицензирован под GPL.

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

Развертывание FFmpeg

Установка и настройка FFmpeg на любой платформе — задача достаточно простая. Однако в нашем случае FFmpeg должен был работать в связке с утилитой для обработки видео. Поскольку эта утилита разворачивается в docker, необходимо, чтобы FFmpeg был настроен в соответствующем образе.

По умолчанию скачать и настроить FFmpeg при сборке образа нельзя. Docker не позволяет скачать и распаковать архив, выдавая ошибку. Поэтому было принято решение положить архив с LGPL версией FFmpeg в корень проекта, что позволит распаковать его и настроить следующим набором команд:

ENV BIN=/usr/bin/
ENV FFmpeg=FFmpeg/
ENV FFmpegBin=/app/FFmpeg/FFmpeg-master-latest-linux64-lgpl/bin
ADD FFmpeg $FFmpeg
ADD $FFmpeg/FFmpeg-master-latest-linux64-lgpl.tar.xz $FFmpeg
RUN rm $FFmpeg/FFmpeg-master-latest-linux64-lgpl.tar.xz \
    && chmod +x $FFmpegBin/FFmpeg \
    && chmod +x $FFmpegBin/ffprobe \
    && ln -s $FFmpegBin/FFmpeg $BIN \
    && ln -s $FFmpegBin/ffprobe $BIN

Способы использования FFmpeg в .NET

Существует несколько способов, как задействовать FFmpeg в .NET приложениях. Первый способ — это использование готовых сторонних библиотек, например, FFmpeg.NET. Такие библиотеки предоставляют удобный API, что значительно упрощает работу с FFmpeg, но может иметь ограничения по лицензированию.

Пример кода работы с библиотекой FFmpeg.NET:

var input = "input.mp4";
var output = "output.mp4";

await FFmpeg
    .Convert(input)
    .Output(output)
    .Resize(320, 240)
    .OnProgress(progress => Console.WriteLine($"Progress: {progress}"))
    .RunAsync();

Более сложным способом, предоставляющим максимальный контроль, но требующим больше затрат, является использование P/Invoke для вызова нативных функций FFmpeg.

[StructLayout(LayoutKind.Sequential)]
public struct AVFormatContext
{
    public IntPtr pbuffer;
    // Другие поля...
}

[DllImport("avformat.dll")]
static extern int av_open_input_file(out AVFormatContext ptr, string url, IntPtr opaque, int flags);

[DllImport("avformat.dll")]
static extern int av_find_stream_info(IntPtr format_context_ptr);

// Другие декларации...

Дополнительно можно написать обертки для нативных функций и работать с ними. Однако при использовании данного подхода следует учитывать следующие моменты:

  • Все требуемые структуры и типы должны быть определены корректно.

  • Марширование структур нужно выполнять вручную после вызова функций.

  • Необходимо очищать ресурсы после их использования.

  • Следует учитывать возможные изменения в именах dll и номерах версий.

И последний путь, по которому мы решили пойти, — это использование класса Process и запуск FFmpeg команд через процесс, которое позволит легко запускать любую команду FFmpeg без необходимости писать сложный код с использованием P/Invoke, а также гибко формировать команду и ее параметры, в зависимости от требований.

Обертка ProcessRunner над Process

Для работы с FFmpeg через Process был написан ProcessRunner — обертка для вызова команд. Она позволяет не только запускать команды, но и логировать работу процесса и ошибок при их выполнении.

ProcessRunner содержит единственный метод RunCommandAsync, принимающий два параметра: ProcessCommand и ProcessEventHandler.

ProcessCommand — это класс, содержащий в себе все необходимое для запуска команды, а также функцию для идентификации ошибки при выполнении команды. Код данного класса представлен ниже:

public class ProcessCommand
{
    /// <summary>
    /// Имя исполняемого файла программы (FFmpeg, ffprobe)
    /// </summary>
    public string ExecutableFile { get; }

    /// <summary>
    /// Исполняемая команда ("-hide_banner -i {fileContext.SourceVideoPath} -show_error -show_format -show_streams -print_format json -o {fileContext.VideoMetadataPath}")
    /// </summary>
    public string CommandArgs { get; }

    /// <summary>
    /// Функция, анализирующая логи выполнения процесса и идентифицирующая наличие ошибки, при выполнении команды
    /// </summary>
    public Func<string, bool> ErrorDetector { get; }

    public ProcessCommand(string executableFile, string commandArgs, Func<string, bool> errorDetector = null)
    {
        ExecutableFile = executableFile;
        CommandArgs = commandArgs;
        ErrorDetector = errorDetector;
    }

    public string OneLineCommandArgs => CommandArgs.NormalizeView();

    public string CommandView => $"{ExecutableFile} {CommandArgs.NormalizeView(Environment.NewLine)}";

    public override string ToString() => CommandView;
}

ProcessEventHandler — класс, позволяющий имплементировать логику обработки выполняемой команды процессом. Например, отслеживать выполнение операции и обновлять у пользователя прогресс обработки видео.

public sealed class ProcessEventHandler : IProcessEventHandler
{
    public TimeSpan TimerInterval { get; set; }
    public Func<Task> ProcessStartingFunc { get; set; }
    public Func<ProcessExecutingArgs, Task> ProcessExecutingFunc { get; set; }

    public async Task OnProcessExecuting(ProcessExecutingArgs args)
    {
        if (ProcessExecutingFunc == null)
            return;

        await ProcessExecutingFunc(args);
    }

    public async Task OnProcessStarting()
    {
        if (ProcessStartingFunc == null)
            return;

        await ProcessStartingFunc();
    }
}

Ниже представлен код класса ProcessRunner:

public class ProcessRunner
{
    /// <summary>
    /// Запуск исполняемого файла в отдельном процессе  
    /// </summary>
    /// <param name="command">Определенная команда на выполнение</param>
    /// <param name="eventHandler">Обработчик событий процесса</param>
    public static async Task<ProcessCommandExecResult> RunCommandAsync(ProcessCommand command, IProcessEventHandler eventHandler = null)
    {

        //Инициализируем обработчик событий
        using var eventListener = eventHandler == null ? null : new ProcessEventListener(eventHandler);

        //Создаем процесс и настраиваем его
        using var process = new Process();
        process.StartInfo = new ProcessStartInfo
        {
            FileName = command.ExecutableFile,
            Arguments = command.OneLineCommandArgs,
            CreateNoWindow = false,
            UseShellExecute = false,
            RedirectStandardInput = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true
        };

        process.EnableRaisingEvents = true;

        //Подписываемся на события OutputDataReceived и ErrorDataReceived для логирования выполнения процесса
        var runningCommandLogs = new StringBuilder();
        process.OutputDataReceived += (sender, e) =>
        {
            runningCommandLogs.AppendLine(e.Data);
            eventListener?.OnProcessRunning(e.Data);
        };

        var executionCommandLogs = new StringBuilder();
        process.ErrorDataReceived += (sender, e) =>
        {
            executionCommandLogs.AppendLine(e.Data);
            eventListener?.OnProcessExecuting(e.Data);
        };

        eventListener?.OnProcessStarting();

        //Запускаем процесс
        process.Start();
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        //Дожидаемся завершение процесса и закрываем его
        await process.WaitForExitAsync();

        process.Close();

        eventListener?.OnProcessFinished();

        //Возвращаем логи выполнения команды и логи ошибок
        return new ProcessCommandExecResult
        {
            RunningCommandLog = runningCommandLogs.ToString(),
            ExecutionCommandLog = executionCommandLogs.ToString(),
            HasError = command.ErrorDetector?.Invoke(executionCommandLogs.ToString()) ?? false
        };
    }
}

Использование Minio для хранения видео

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

Minio является объектным хранилищем, которое предоставляет дополнительный слой абстракции над файловой системой и хостом и позволяет работать с файлами через API.

Объектное хранилище может помочь в кейсах, когда необходимо хранить файлы пользователей в ваших приложениях, складывать статику и предоставлять доступ к ней через Ingress или хранить кеши вашего CI. 

Настраивается хранилище одной командой, но лучше потратить чуть больше времени, чтобы получить полноценное готовое решение с шифрованием, защитой от повреждения данных. С последним у Minio все впорядке и он может позволить себе пережить потерю половины дисков. Более подробно про настройку можно почитать в Quick start guide.

Хранение видео предполагается сначала в виде чанков с последующим склеиванием в исходное видео. Под чанки выделяется отдельный бакет с дополнительной настройкой TTL для чанков, что позволяет автоматически очищать место и удалять уже ненужные чанки. В свою очередь под каждое видео выделялся свой бакет. Сделано это было для того, чтобы хранить дополнительные файлы (такие как метаданные, видео различных форматов) отдельно от файлов, принадлежащим другим видео.

public async Task SaveAsync(
    string fileId,
    string fileName,
    string path)
{
    using var stream = File.Open(path, FileMode.Open);

    await PutObjectAsync(fileId, stream, fileName);
}

public async Task SaveChunkAsync(
    Stream stream,
    VideoChunkUploadingInfo videoChunkInfo,
    string fileName)
{
    var bucket = BucketType.VideoChunks.GetBucketName();
    var objectKey = new VideoChunkObjectKey(videoChunkInfo.FileId, videoChunkInfo.ChunkNumber).ToString();
    var contentType = FileContentHelper.GetContentType(fileName);
    var headers = new Dictionary<string, string>
    {
        { "FileId", videoChunkInfo.FileId },
        { "ChunkNumber", videoChunkInfo.ChunkNumber.ToString() },
        { "ChunkSize", stream.Length.ToString() }
    };

    await PutObjectAsync(bucket, stream, objectKey, contentType, headers);
}

private async Task PutObjectAsync(
    string bucket,
    Stream stream,
    string objectKey,
    string contentType = null,
    Dictionary<string, string> headers = null)
{
    await _MinioClient.CreateBucketIfNotExistsAsync(bucket);
    await _MinioClient.PutObjectAsync(new PutObjectArgs()
        .WithBucket(bucket)
        .WithObject(objectKey)
        .WithStreamData(stream)
        .WithObjectSize(stream.Length)
        .WithContentType(contentType)
        .WithHeaders(headers));
}

Загрузка исходного видео

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

На первом шаге мобильное приложение делает REST запрос на инициализацию загрузки видео со следующей request-моделью:

public class VideoUploadingRequest
{
    /// <summary>  Клиентский идентификатор файла </summary>
    public Guid ClientFileId { get; set; }

    /// <summary> Название файла </summary>
    public string FileName { get; set; }

    /// <summary> Размер файла в байтах </summary>
    public long FileSizeInBytes { get; set; }
}

В ответе метода сервер возвращает следующую модель:

public class VideoUploadingResponse
{
    /// <summary> Идентификатор видео в системе </summary>
    public string FileId { get; set; }

    /// <summary> Секретный ключ загрузки чанков </summary>
    public string SecretKey { get; set; }

    /// <summary> Клиентский идентификатор видео </summary>
    public Guid ClientFileId { get; set; }

    /// <summary> Размер чанка </summary>
    public long ChunkSizeInBytes { get; set; }

    /// <summary> Количество чанков </summary>
    public int TotalChunksCount { get; set; }

    /// <summary> Интервал запроса данных для обновления процесса загрузки видео </summary>
    public TimeSpan ProgressPollingInterval { get; set; }
}

Данный шаг позволяет гибко настраивать на сервере дальнейший процесс загрузки чанков за счет параметров ChunkSizeInBytes и TotalChunksCount, влияющие на количество чанков, на которое будет разбито исходное видео и как следствие на количество запросов, необходимых для загрузки всех чанков на сервер для дальнейшей обработки. Дополнительным плюсом является варьирование параметра ProgressPollingInterval, отвечающего за интервал между запросами на сервер для обновления данных загрузки и отображения процента загрузки на клиенте. 

Вторым шагом клиент начинает параллельную загрузку чанков на сервер. Сам чанк передается байтами в теле POST запроса, а дополнительные данные в хедерах (идентификатор файла и номер чанка). Контент прочитывается из запроса в MemoryStream и передается в VideoService, где проходит валидацию и сохраняется в Minio.

[HttpPost("[action]")]
public async Task<ChunkUploadingResponse> UploadAsync(VideoChunkUploadingRequest request)
{
    var videoChunkInfo = _mapper.Map<VideoChunkUploadingInfo>(request);

    using var videoChunkStream = new MemoryStream();
    await Request.Body.CopyToAsync(videoChunkStream);
    videoChunkStream.Position = 0;

    return await _videoService.UploadChunkAsync(videoChunkInfo, videoChunkStream);
}

В параллельной загрузке чанков есть один нюанс. Заключается он в том, что при сохранении последнего чанка мы должны опубликовать в очередь идентификатор файла, который слушает консьюмер, отвечающий за обработку видео. Однако существует риск дублирования сообщения в очереди. Проблема кроется в параллельной обработке последних чанков и проверке количества загруженных чанков.

Для решения этой проблемы был использован Redis и его атомарные операции. Когда количество загруженных чанков достигает общего числа, мы пытаемся получить fileId из Redis. Если fileId равен null, это означает, что данный чанк — последний. В этом случае отправляется сообщение в очередь, и fileId сохраняется в Redis для предотвращения повторной отправки.

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

public async Task<string> AddAtomicAndGetPreviousValueAsync(string fileId)
{
    var key = RedisHelper.GetRedisKey(_settings.VideoUploading.Prefix, fileId);
    var oldValue = await _redisDatabase.StringGetSetAsync(key, fileId);
  
    await _redisDatabase.KeyExpireAsync(key, TimeSpan.FromSeconds(_settings.VideoUploading.ExpirationCacheInSeconds));

    return oldValue;
}

Кэширование vs стриминг

На данном этапе нужно было определиться, как клиентское приложение будет получать видео с backend’а.

Первый подход — это кэширование видео на стороне клиента. Приложение должно полностью скачать видео и только затем разрешать запускать его в плеере. К плюсам данного подхода можно отнести: скачивание файла и дальнейшее его воспроизведение не только внутри приложения, но и другими сторонними плеерами, а также возможность без задержек перемотать видео на любой тайминг. К очевидным минусам относится необходимость полностью скачать файл, для дальнейшей работы с ним. В случае, если произойдет ошибка или разрыв соединения, то придется начинать скачивание заново, что, вероятно, приведет в будущем к негативным отзывам пользователей.

Второй подход — это стриминг. Он заключается в непрерывной передаче аудио- или видеофайлов с сервера на клиент. Клиент не скачивает файл полностью, а начинает запрашивать отдельные фрагменты, что значительно ускоряет получение видео, а также его воспроизведение. При этом видео не сохраняется на устройстве, что также экономит место. В дополнение, медиаплееры загружают несколько секунд потока, чтобы видео могло продолжить воспроизведение, если произошла кратковременная потеря соединения с интернетом — этот процесс называется “буферизацией видео”.

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

Сравнение протоколов передачи данных DASH и HLS

Также необходимо было определиться с протоколом передачи данных, а именно DASH или HLS.

MPEG-DASH — это метод потоковой передачи. DASH означает “динамическая адаптивная потоковая передача через HTTP”. Поскольку он основан на HTTP, любой исходный сервер можно настроить для обслуживания потоков MPEG-DASH.

MPEG-DASH имеет сходство с протоколом HLS, который также предназначенный для потоковой передачи видео. Оба протокола разбивают видео на небольшие фрагменты, кодируя их с разным качеством. Такой подход позволяет менять качество видео прямо во время воспроизведения, обеспечивая пользователям бесперебойный просмотр, даже если скорость соединения меняется. Кроме того, они также используют TCP в качестве транспортного протокола.

Однако между ними имеется несколько ключевых отличий:

  • Форматы кодирования: MPEG-DASH позволяет задействовать любой стандарт кодирования. HLS требует использования H.264 или H.265.

  • Поддержка устройств: HLS — единственный формат, поддерживаемый устройствами Apple. iPhone, MacBook и другими продуктами Apple. Они не могут воспроизводить видео, передаваемое через MPEG-DASH.

  • Длина сегмента: До 2016 года между протоколами HLS и MPEG-DASH была значительная разница в длине сегментов. По умолчанию длина сегмента для HLS составляла 10 секунд. Сегодня она уменьшена до 6 секунд, хотя это значение можно изменить. Сегменты MPEG-DASH, как правило, имеют длину от 2 до 10 секунд, при этом оптимальной считается длина от 2 до 4 секунд.

  • Стандартизация: MPEG-DASH является международным стандартом, тогда как HLS, разработанный Apple, не опубликован как международный стандарт, хотя и обладает широкой поддержкой.

Взвесив все за и против, мы решили остановиться на HLS.

Обработка видео с помощью FFmpeg

После успешной загрузки видео запускается процесс его генерации. За генерацию отвечает отдельная утилита, которая прослушивает очередь. При получении сообщения утилита проходит все этапы генерации видео. В случае ошибки сообщение возвращается в конец очереди. При повторных обработках завершенные ранее этапы пропускаются.

Всего этапов 6:

  • склеивание чанков и сохранение оригинального видео;

  • сохранение метаданных видео;

  • генерация превью изображений;

  • генерация блюр фона;

  • генерация видео низкого разрешения;

  • генерация видео высокого разрешения.

Для реализации всех шагов был использован паттерн “Шаблонный метод”. Шаблонный метод определяет алгоритм, некоторые этапы которого делегируются подклассам, что позволяет им переопределять этапы, не меняя структуру алгоритма.

Шаблонный метод реализован следующим образом: сначала проверяется, был ли этап уже выполнен и требуется ли его повторное выполнение. Затем создаётся точка прогресса, запускается команда FFmpeg, обновляются метаданные, сохраняется сгенерированный файл и фиксируется прогресс обработки видео.

Ниже приведены команды FFmpeg для генерации метаданных, превью и видео:

Генерация метаданных

ffprobe -hide_banner -i {fileContext.SourceVideoPath} -show_error -show_format -show_streams -print_format json -o {fileContext.VideoMetadataPath}

Генерация превью

FFmpeg            
  -hide_banner -y -i {fileContext.SourceVideoPath}
  -filter_complex ""[0]split=7[i1][i2][i3][i4][i5][i6][i7];
  [i1]copy[i_0];
  [i2]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P1080)}[i_1080];
  [i3]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P700)}[i_700];
  [i4]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P500)}[i_500];
  [i5]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P240)}[i_240];
  [i6]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P150)}[i_150];
  [i7]{GetScale(fileContext.Metadata.Info.Orientation, ImageResolution.P72)}[i_72]""
  -map ""[i_0]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_0.jpg
  -map ""[i_1080]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_1080.jpg
  -map ""[i_700]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_700.jpg
  -map ""[i_500]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_500.jpg
  -map ""[i_240]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_240.jpg
  -map ""[i_150]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_150.jpg
  -map ""[i_72]"" {frameTimestamp} -update 1 -frames:v 1 {fileContext.Path}/preview_72.jpg

Генерация блюр

FFmpeg -hide_banner -y -i {fileContext.SourceVideoPath} {frameTimestamp} -s 8x8 -frames:v 1 {fileContext.VideoBlurryPreviewPath}

Генерация видео

FFmpeg
  -hide_banner -y -i {sourcePath} `
  -filter_complex "[0:v]split=3[v0][v1][v2]; `
  [v0]scale=w=-2:h=360[v0out]; `
  [v1]scale=w=-2:h=480[v1out]; `
  [v2]scale=w=-2:h=720[v2out]" `
  -map "[v0out]" -c:v:0 libopenh264 -profile:v:0 main -allow_skip_frames 1 -b:v:0 2M -maxrate:v:0 2M -minrate:v:0 2M -bufsize:v:0 2M -g 20 `
  -map "[v1out]" -c:v:1 libopenh264 -profile:v:1 main -allow_skip_frames 1 -b:v:1 3M -maxrate:v:1 3M -minrate:v:1 3M -bufsize:v:1 3M -g 20 `
  -map "[v2out]" -c:v:2 libopenh264 -profile:v:2 main -allow_skip_frames 1 -b:v:2 4M -maxrate:v:2 4M -minrate:v:2 4M -bufsize:v:2 4M -g 20 `
  -map a:0 -c:a:0 aac -b:a:0 64k -ac 2 `
  -map a:0 -c:a:1 aac -b:a:1 96k -ac 2 `
  -map a:0 -c:a:2 aac -b:a:2 128k -ac 2 `
  -f hls `
  -var_stream_map "v:0,a:0,name:360p v:1,a:1,name:480p v:2,a:2,name:720p" `
  -hls_time 2 `
  -hls_list_size 0 `
  -hls_segment_type mpegts `
  -hls_segment_filename {outputPath}\%v_%06d.ts `
  -master_pl_name master.m3u8 `
  {outputPath}\%v_stream.m3u8

Мониторинг прогресса обработки видео на сервер

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

Процесс загрузки и обработки был разделен на несколько этапов, образуя своего рода дерево процессов.

Первый этап: загрузка и предварительная обработка. Он включает в себя загрузку видео фрагментов на сервер, сохранение оригинального видео, предварительную подготовку HLS средствами FFmpeg, генерацию превью, генерацию metadata и генерацию блюра для видео. Именно данный этап отображается пользователю в виде лоадера. 

В процентном соотношении 40% отводится под отображение процесса загрузки видео фрагментов на сервер, а оставшиеся 60% под предварительную обработку видео и генерацию HLS в низком качестве. Дополнительно для разработчиков есть разделение предварительной обработки на более мелкие этапы мониторинга для детализации процесса.

Второй этап — постобработка видео на сервере, которая включает логирование обработки HLS высокого качества. Пользователь не видит информации об этом этапе, так как уже получил доступ к просмотру видео в низком качестве, а улучшенное качество генерируется в фоновом режиме.

Итоги

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

Всем спасибо за внимание!

Комментарии (0)