Интеграция измерения качества видео в CI/CD

Автор: Николай СапуновОбновлено: август 202616 мин чтения
Содержание статьи +

Кратко

Интеграция измерения качества в конвейер непрерывной интеграции и доставки (CI/CD) означает запуск перцептивной метрики автоматически на каждое изменение – достаточно надёжно, чтобы числу можно было доверять, и достаточно дёшево, чтобы конвейер оставался быстрым. Работа делится на четыре задачи проводки: разделить кодирование, измерение и решение на отдельные стадии; зафиксировать инструмент измерения, модель и эталонный клип внутри контейнера, чтобы каждый раннер давал одинаковую оценку; кэшировать эти входы, чтобы сетевой сбой не валил сборку; и отчитываться там, где изменение ревьюят. Дешёвую проверку на каждый pull request гоните на CPU-раннере, а измерение полного каталога – на GPU, где расчёт оценки Video Multimethod Assessment Fusion (VMAF) для 1000 часов 4K идёт примерно в шесть раз быстрее и почти на 75% дешевле. Логика решения – какая метрика, какой порог, жёсткий или мягкий гейт – лежит в статье про гейты качества; эта статья – проводка, которая заставляет тот гейт работать на каждом изменении, не обманывая и не тормозя.

Зачем это нужно

Гейт качества, который запускается только на ноутбуке инженера, – это не гейт, а доброе намерение. Ценность перцептивной проверки появляется лишь тогда, когда конвейер запускает её автоматически, на каждое изменение, и выдаёт одно и то же число независимо от того, какая машина считала. Эта статья – для платформенного или DevOps-инженера, руководителя кодирования и QA-инженера, которому нужно взять логику гейта из гейтов качества в CI/CD и реально встроить её в GitHub Actions, GitLab CI или Jenkins так, чтобы она была воспроизводимой, быстрой и дешёвой. Ошибётесь в проводке – получите худшее из двух миров: медленный конвейер, который падает на шуме незафиксированного инструмента и который команда учится игнорировать. Сделаете правильно – измерение уйдёт в фон и всплывёт лишь тогда, когда изменение действительно сдвинуло картинку.

Задача проводки, по-простому

Короткое дополнение к статье про гейт: там решается, что проверять – гейтить на перцептивную метрику вроде VMAF (оценка качества от Netflix, 0–100, где выше – ближе к оригиналу для человеческого глаза), запускать абсолютный пол плюс проверку регрессии и брать запас по собственному шуму метрики. Эта статья отвечает на следующий вопрос: как запустить эту проверку на каждом изменении, получить один и тот же ответ на любой машине и не разорить бюджет минут сборки? Это инженерная задача, а не измерительная, и у неё четыре части – разделение, воспроизводимость, кэширование и отчёт. Остаток статьи берёт их по порядку.

Прежде них всю конструкцию задаёт одно правило. Сравнение метрик корректно только «яблоки к яблокам»: те же кадры, то же разрешение, тот же эталон, та же метрика и та же версия модели. Каждое решение по проводке ниже существует ради того, чтобы держать эти пять вещей постоянными на тысяче автоматических прогонов – так, чтобы оценку двигало только реальное изменение энкода, что и должен ловить конвейер. Гейт, который запускает эта проводка, – лишь одна контрольная точка в более широкой архитектуре автоматизированного контроля качества; где он стоит среди проверки инжеста и предпубликационных проверок, см. автоматизированный контроль качества в видеопайплайне.

Шаг первый: разделите кодирование, измерение и решение

Самая частая ошибка проводки – слить измерение с кодировщиком, так что переизмерить можно только переэнкодив. Держите три задачи раздельно: стадия, которая кодирует тестовый клип с изменением, стадия, которая измеряет результат относительно эталона, и стадия, которая решает «прошло/не прошло». Каждая пишет вывод в известное место, а следующая его читает.

Netflix усвоил это на масштабе и перестроил конвейер вокруг этого. Их платформа Cosmos намеренно отделяет измерение качества видео от кодирования – именно чтобы можно было обновить метрику или выкатить новую версию VMAF без переэнкодирования каталога (Netflix Technology Blog, Video Quality at Scale with Cosmos Microservices, 2021). Тот же принцип масштабируется и вниз, до небольшого конвейера: если измерение – отдельная стадия, читающая закодированный файл, вы можете переоценить вчерашние энкоды под новой моделью, не трогая кодировщик, и запускать измерение на классе машин, отличном от кодирования.

Рисунок 1. Стройте конвейер как три отделяемые стадии. Кодировщик выдаёт представления; отдельная стадия измерения оценивает их относительно зафиксированного эталона и пишет JSON; стадия решения читает JSON и гейтит. Отделение измерения от кодирования позволяет переоценить под новой моделью без переэнкодирования.

Разделение ещё и даёт сопоставить триггер со стоимостью. Быстрое подмножество – один-два репрезентативных клипа, только верхние представления – гоняется на каждый pull request, где должно быть быстрым. Полная лестница по всему набору эталонов идёт ночью или перед релизом, где минуты значат меньше. Конвейер – те же три стадии; меняется только ширина входа.

Шаг второй: контейнеризируйте измерение, чтобы каждый раннер совпадал

Это сердце CI-интеграции и причина, по которой проверка качества, прошедшая локально, загадочно падает в конвейере. Оценка VMAF зависит от трёх вещей, которым нельзя «плыть»: сборки FFmpeg, библиотеки libvmaf внутри неё и файла модели. Измените любую – и число сдвинется, незаметно переустановив базу под каждым порогом (Netflix/VMAF, документация models, 2026). На ноутбуке разработчика это то, что в последний раз поставил пакетный менеджер; на CI-раннере – то, что пришло в базовом образе. Две машины, две версии FFmpeg, две разные оценки одного энкода – и гейт читает разницу как регрессию, которой нет.

Лечение – обращаться со средой измерения как с версионируемым, неизменяемым входом сборки: запечь зафиксированные FFmpeg, libvmaf и модель в образ контейнера и запускать каждое измерение внутри него. Контейнер – это запечатанная коробка с точными версиями инструментов, поэтому одна и та же коробка даёт одно и то же число на ноутбуке, на хостируемом раннере или на рендер-ферме. Текущая стабильная линия – FFmpeg 8.1.2 (выпущен 2026-06-17), и статические сборки вроде BtbN идут уже с включённым libvmaf; фиксируйтесь на digest образа, а не на «плывущий» тег.

# Dockerfile.measure — запечатанная, воспроизводимая среда измерения
FROM ubuntu:24.04@sha256:<pinned-digest>

# Зафиксированная статическая сборка FFmpeg 8.1.2 (libvmaf включён). Фиксируйте URL/версию.
RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \
 && curl -L -o /tmp/ffmpeg.tar.xz \
      https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-18/ffmpeg-n8.1.2-linux64-gpl-8.1.tar.xz \
 && tar xf /tmp/ffmpeg.tar.xz --strip-components=2 -C /usr/local/bin --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' \
 && pip install --no-cache-dir ffmpeg-quality-metrics==3.* \
 && ffmpeg -version | head -1

# Запеките файл модели VMAF в образ, чтобы он не мог «уплыть» во время выполнения.
COPY models/vmaf_v0.6.1.json /opt/models/vmaf_v0.6.1.json

Команда измерения, которую запускает контейнер, – предмет статьи измерение качества с FFmpeg и libvmaf; общая механика вызова FFmpeg относится к шпаргалке по FFmpeg раздела Video Encoding (FFmpeg cheat sheet). Эта статья настаивает лишь на одном: какую бы команду вы ни запускали, запускайте её из зафиксированного образа.

Одну тонкость контейнер за вас не спрячет: пути CPU и GPU через VMAF не дают побитово одинаковых оценок. Математика на видеокарте идёт в чуть ином порядке, поэтому те же файлы и та же модель могут различаться на доли балла между фильтром libvmaf (CPU) и libvmaf_cuda (GPU) (Mason K, DEV, 2026). Выберите один путь для гейта и держитесь его; смешивание путей между сборками возвращает тот самый дрейф, который контейнер убрал. Запишите, какой путь, какая версия FFmpeg и какая модель использованы в прогоне, – так же, как наша методология бенчмарков штампует происхождение на каждое опубликованное число.

Рисунок 2. Контейнеризируйте измерение. Сборка FFmpeg, библиотека libvmaf и файл модели – три входа, что двигают оценку VMAF; зафиксируйте все три в неизменяемом образе, чтобы каждый раннер совпадал. Пути CPU и GPU чуть отличаются – выберите один и запишите его.

Шаг третий: кэшируйте эталон и модель, никогда не тяните «вживую»

Контейнер фиксирует инструменты; входы измерения – эталонный мастер и файл модели – требуют того же, но по другой причине. Эталонный клип (нетронутый оригинал, с которым VMAF сравнивает, его часто зовут золотым эталоном; см. регрессионное тестирование и золотые эталоны) часто велик, и ленивая проводка тянет его с публичного URL в начале каждого прогона. Это превращает сбой content-delivery-network или переехавший файл в красную сборку, не имеющую отношения к энкоду. Тот же риск – у модели, скачиваемой из репозитория на каждом прогоне.

Храните оба как версионируемые кэшированные артефакты. Держите эталонный мастер в объектном хранилище или Git Large File Storage (Git LFS – расширение, что держит большие бинарники вне основного репозитория), и тяните его через кэш CI по ключу из хеша содержимого, чтобы он скачивался один раз и переиспользовался, пока действительно не изменится (Mason K, DEV, 2026). Файл модели достаточно мал, чтобы закоммитить напрямую или запечь в образ, как делает Dockerfile выше. Ключ кэша по хешу, а не по имени, – это и делает эталон неизменяемым: если файл изменился, меняется ключ, кэш промахивается, и новая база становится явной, а не молчаливой.

# Кэшируем эталонный мастер по хешу содержимого, чтобы он скачивался один раз.
- name: Restore reference master
  uses: actions/cache@v4
  with:
    path: reference/
    key: ref-master-${{ hashFiles('reference/master.sha256') }}

Выигрыш в том, что единственная сетевая зависимость, оставшаяся на горячем пути, – это подтягивание контейнера, которое CI тоже кэширует по digest. Тогда со стороны измерения сборка может упасть ровно по одной причине: энкод изменил картинку.

Шаг четвёртый: масштаб – разворачивайте веером и знайте, когда брать GPU

Проверка на pull request дёшева; переизмерение полного каталога – нет, и смешение этих двух случаев – путь к тому, чтобы либо пропускать измерение, либо платить за GPU, который не нужен. Арифметика всё решает.

На pull request стоимость пренебрежима. Возьмём лестницу из пяти представлений десятисекундного клипа 1080p при 30 кадрах в секунду: каждое представление – 300 кадров, итого 5 × 300 = 1500 кадров на измерение. На CPU-узле однопоточный VMAF идёт около 176 кадров в секунду на 1080p (NVIDIA, 2024), так что измерение – это 1500 ÷ 176 ≈ 8,5 секунды счёта. Добавьте кодирование – и у вас проверка, завершающаяся за минуту-две на стандартном хостируемом раннере. Гоняйте её на каждое изменение.

На масштабе каталога картина переворачивается. Переоценка 1000 часов 4K при 30 кадрах в секунду – это 108 миллионов кадров. Насыщенный двухпроцессорный 2U-узел измеряет VMAF 4K около 235 кадров в секунду, так что 108 000 000 ÷ 235 ≈ 459 600 секунд ≈ 127,6 часа. 2U-сервер с восемью GPU достигает около 1424 кадров в секунду, завершая ту же работу за 108 000 000 ÷ 1424 ≈ 75 800 секунд ≈ 21,0 часа – примерно 6-кратное ускорение при том же энергопотреблении, и по стоимостной модели NVIDIA около $24 против $97 за прогон, почти 75% экономии (NVIDIA, 2024). GPU-ускорение через фильтр libvmaf_cuda идёт начиная с VMAF 3.0 и FFmpeg 6.1, и продакшен-команды сообщают тот же порядок экономии: Snap перевёл проверки качества Snapchat Memories на GPU и переэнкодит, только когда оценка не дотягивает; V-Nova, обрабатывая около 20 000 заданий кодирования в неделю, намерила минимум 2-кратное ускорение (NVIDIA, 2024).

Так что правило простое: распараллеливайте и кладите работу на правильную машину. Разворачивайте представления веером по параллельным заданиям – matrix-сборки CI делают это нативно, одно задание на ступень или на клип, – чтобы время по стене масштабировалось с числом раннеров, а не с глубиной лестницы. Дешёвую проверку на pull request держите на недорогих хостируемых CPU-раннерах. Тяжёлые ночные или предпубликационные прогоны переносите на самостоятельно размещённые GPU-раннеры (GitLab включает это через gpus = "all" в конфиге раннера и базовый образ NVIDIA CUDA; GitHub поддерживает self-hosted GPU-раннеры так же).

Где идёт измерениеПропускная (один поток 4K)Лучше всего дляЧего опасаться
Хостируемый CPU-раннер~64 fpsПроверка на PR на малом подмножестве клиповМедленно/дорого на длинных клипах или полной лестнице
Self-hosted CPU-узел (насыщенный)~235 fpsСредние ночные прогоны, GPU недоступенНужно много параллельных процессов; планирование ёмкости
Self-hosted GPU-раннер (libvmaf_cuda)~178 fps один / ~1424 fps (8 GPU)Переизмерение полного каталога, большие ночные прогоныОценки отличаются от CPU – не смешивайте пути в одном гейте

Таблица 1. Сопоставление раннера задаче. Проверки на PR – это секунды счёта, им место на дешёвых CPU-раннерах; переизмерение масштаба каталога – это GPU-задача. Цифры пропускной – из измерений NVIDIA L4-против-двух-Xeon-8480, 2024; ваше железо будет иным, поэтому измеряйте своё.

Рисунок 3. Масштабируйтесь веером. Каждое представление становится параллельным заданием измерения, так что время по стене следует за числом раннеров, а не за глубиной лестницы. Дешёвое подмножество на PR идёт на CPU; дорогой полный прогон каталога – на GPU, где VMAF 4K примерно в 6 раз быстрее и на 75% дешевле.

Собираем вместе: конфиг конвейера

Когда четыре части на месте, сам конфиг короток. Шаблон ниже срабатывает только при изменении пути кодирования, запускает измерение внутри зафиксированного контейнера и передаёт результат стадии решения. Это GitHub Actions; GitLab CI и Jenkins выражают те же три идеи – триггер, контейнеризированный шаг измерения, загрузка артефакта – другим синтаксисом.

# .github/workflows/quality.yml
name: quality-measurement
on:
  pull_request:
    paths: ["encoder/**", "ladder/**"]   # только когда энкод может измениться

jobs:
  measure:
    runs-on: ubuntu-24.04
    container: ghcr.io/your-org/ffmpeg-measure@sha256:<pinned-digest>   # шаг два
    strategy:
      matrix:
        rung: [1080p, 720p, 480p]        # шаг четыре: одно задание на представление
    steps:
      - uses: actions/checkout@v4
      - name: Restore reference master   # шаг три
        uses: actions/cache@v4
        with:
          path: reference/
          key: ref-master-${{ hashFiles('reference/master.sha256') }}
      - name: Encode + measure ${{ matrix.rung }}   # шаг один: кодируем, затем измеряем
        run: ./ci/measure.sh ${{ matrix.rung }} > metrics-${{ matrix.rung }}.json
      - name: Upload metrics                         # шаг пять: отчёт
        uses: actions/upload-artifact@v4
        with:
          name: metrics-${{ matrix.rung }}
          path: metrics-${{ matrix.rung }}.json
          retention-days: 30

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

Шаг пятый: отчитывайтесь так, чтобы результат попадал туда, где работа

Измерение, которого никто не видит, ничего не меняет. Три вывода делают результат действенным. Первое – пишите покадровые оценки как артефакт сборки и держите их минимум 30 дней: агрегат говорит, что энкод регрессировал, а покадровая кривая – что он регрессировал на две секунды начиная с кадра 1400, и это то, что инженеру нужно, чтобы найти причину (Mason K, DEV, 2026). Превращение этой кривой в картинку, которую ревьюер читает с одного взгляда, – задача визуализации качества: теплокарты и графики, а чтение худших кадров вместо среднего разобрано в пулинге покадровых оценок. Второе – постите сводку обратно в pull request (среднее, нижний перцентиль и дельту от базы, по каждому представлению), чтобы число было видно там, где ревьюят изменение, а не похоронено в логе. Третье – выдавайте машиночитаемый отчёт JUnit XML, чтобы интерфейс CI нативно рисовал зелёную или красную строку на каждое представление, как для юнит-тестов.

Замечание о том, что так измерить нельзя. VMAF – полноэталонная метрика: ей нужен нетронутый оригинал. CI-гейт на ней может проверять только энкоды, для которых у вас есть мастер, – а это большинство продакшен-задач кодирования. Он не может оценить прямой эфир или пользовательский поток, у которого в точке измерения эталона нет, – этому случаю нужна no-reference метрика и другой конвейер, разобранный в no-reference качестве для live и UGC.

Частая ошибка: невоспроизводимый раннер

Сбой проводки, который тратит больше всего времени, – это невоспроизводимый раннер. Разработчик меряет VMAF 93,4 локально; CI-раннер на другой сборке FFmpeg с моделью, свежескачанной из интернета, меряет 92,1 для того же энкода; гейт читает «регрессию» в 1,3 балла и блокирует чистое изменение. Ничего не регрессировало – две машины считали разные измерения. Команда жжёт полдня в погоне за несуществующим багом кодировщика, а потом теряет доверие к гейту. Лечение – всё вышеописанное: зафиксировать FFmpeg, libvmaf и модель в одном контейнере; кэшировать эталон по хешу содержимого; выбрать путь CPU или GPU и не смешивать их; записать полное окружение прогона. Вторая, тихая версия ошибки – гонять измерение полного каталога на каждый коммит: корректно, воспроизводимо и слишком медленно и дорого, пока кто-нибудь это не отключит. Сопоставляйте ширину измерения триггеру: малое подмножество на PR, полный прогон по расписанию. Гейт зарабатывает право блокировать тем, что он одновременно прав и быстр.

Где здесь Фора Софт

Фора Софт с 2005 года строит системы видеостриминга, OTT, видеоконференций, e-learning, видеонаблюдения и телемедицины, и на продуктах с собственным конвейером кодирования описанная здесь проводка измерения – это то, что не даёт рутинному изменению кода тихо ухудшить картинку в продакшене. Мы помогаем командам поднять воспроизводимую стадию измерения – зафиксированный контейнер с FFmpeg и libvmaf, кэш эталона по хешу содержимого, веер по лестнице представлений и отчёт, постящийся обратно в pull request, – и связать её с логикой гейта, которая решает «прошло/не прошло». Где проекту нужны данные «битрейт-качество» за конкретным порогом для данного кодека и типа контента, их дают наши измеренные бенчмарки (см. нашу методологию бенчмарков). Цель та же, что у хороших юнит-тестов: проверка, что молчит, когда с энкодом всё в порядке, и однозначна, когда нет.

Ключевые выводы

  • Разделите кодирование, измерение и решение на отдельные, перезапускаемые стадии.
  • Зафиксируйте FFmpeg, libvmaf и модель в контейнере, чтобы каждый раннер давал одну оценку.
  • Пути CPU и GPU чуть различаются – выберите один для гейта и не смешивайте.
  • Кэшируйте эталонный мастер и модель по хешу содержимого; не тяните вживую на горячем пути.
  • Дешёвую проверку на PR – на CPU; GPU-раннеры – для полных прогонов каталога (~6× быстрее, ~75% дешевле).
  • Отчитывайтесь покадровым артефактом, комментарием в PR и JUnit XML, чтобы результат попадал к работе.

Что почитать дальше

Строите такую систему?

Подберём параметры кодирования под ваш контент и посчитаем стоимость доставки до старта разработки.