Объединение видеофрагментов с нескольких камер и синхронизация их по времени

от автора

В системе дистанционного надзора (СДН), обзор которой был сделан в предыдущей статье, для управления медиапотоками используется медиасервер Kurento, позволяющий записывать потоки, где каждый поток — это отдельный файл. Проблема заключается в том, что при просмотре протокола экзамена нужно воспроизводить три потока одновременно с синхронизацией потоков по времени (веб-камера испытуемого со звуком, веб-камера проктора со звуком и рабочий стол испытуемого), причем на протяжении всего экзамена каждый поток может быть разбит на несколько фрагментов. Эта статья о том, как удалось решить данную проблему, а также организовать сохранение видеозаписей на WebDAV сервер всего одним bash-сценарием.

Воспроизведение видеоархива СДН

Медиасервер Kurento сохраняет медиапотоки в оригинальном виде, как они передаются с клиента, фактически осуществляется дамп потока в файл формата webm, используются кодеки vp8 и vorbis (также есть поддержка формата mp4). Это приводит к тому, что сохраненные файлы имеют переменное разрешение видео и переменный битрейт, т.к. WebRTC динамически меняет параметры кодирования видео- и аудиопотков в зависимости от качества каналов связи. В течении каждой сессии прокторинга клиенты могут несколько раз устанавливать связь и прерывать соединение, что приводит к появлению множества файлов для каждой камеры и экрана, а также появляется рассинхронизация во времени, если потом все эти фрагменты склеить вместе.

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

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

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

Каждая сессия прокторинга в СДН имеет свой уникальный идентификатор, который передается Kurento при установлении соединения между испытуемым и проктором. В рамках этой сессии создаются три потока, которые могут прерываться и возобновляться по техническим причинам или по инициативе проктора. Для именования видеофайлов, которые сохраняются Kurento, был выбран формат “timestamp_camera-session.webm” (маска в виде регулярного выражения ^[0-9]+_[a-z0-9]+-[0-9a-f]{24}.webm$), где timestamp — временная метка создания файла в миллисекундах; camera — идентификатор камеры, чтобы отличать потоки с веб-камеры испытуемого (camera1), веб-камеры проктора (camera2) и поток с картинкой рабочего стола (screen); session — идентификатор сессии прокторинга. После каждой сессии прокторинга сохраняется множество видеофрагментов, возможные варианты фрагментации видеозаписей приведены на рисунке ниже.

Возможные варианты фрагментации видеозаписей

Числа 1-12 это некие временные метки; жирная линия — это видеофрагменты различной продолжительности; пунктирная линия — недостающие фрагменты, которые нужно добавить; пустые промежутки — интервалы времени, в которых нет никаких видеофрагментов, должны быть исключены из итоговой видеозаписи.

Выходной видеофайл представляет собой блок из трех частей, две камеры с разрешением 320×240 (4:3) и один экран с разрешением 768×480 (16:10). Исходное изображение следует масштабировать до заданного размера. Если соотношение сторон не соответствует данному формату, то уместить всё изображение в центре заданного прямоугольника, пустые области закрасить черным цветом. В итоге расположение камер должно выглядеть как на картинке ниже (синий и зеленый — веб-камеры, красный — рабочий стол).

Расположение камер на комплексном экране

В итоге каждая сессия прокторинга вместо множества отрывков, имеет только один видеофайл с записью всей сессии. Помимо всего прочего, выходной файл занимает меньше места, т.к. уменьшается частота кадров видео до минимального приемлемого числа 1-5 кадров/с. Получившийся файл загружается на WebDAV-сервер, куда СДН обращается за этим файлом через соответствующий интерфейс с учетом необходимых прав доступа. Протокол WebDAV достаточно распространенный, потому хранилище может быть чем угодно, для этих целей можно даже использовать Яндекс.Диск.

Реализацию всех этих функций удалось уместить в небольшой bash-сценарий, для которого дополнительно понадобятся утилиты ffmpeg и curl. Для начала нужно перекодировать видеофайлы с динамическим разрешением и битрейтом, задав необходимые параметры для каждой камеры. Функция перекодирования исходного видеофайла с заданным разрешением и числом кадров в секунду выглядит так:

scale_video_file() {     local in_file="$1"     local out_file="$2"     local width="$3"     local height="$4"     ffmpeg -i "$in_file" -c:v vp8 -r:v ${FRAME_RATE} -filter:v scale="'if(gte(a,4/3),${width},-1)':'if(gt(a,4/3),-1,${height})'",pad="${width}:${height}:(${width}-iw)/2:(${height}-ih)/2" -c:a libvorbis -q:a 0 "${out_file}" }

Особое внимание стоит уделить scale-фильтру ffmpeg, он позволяет подогнать картинку под заданное разрешение, даже если соотношение сторон различается, заполнив образовавшееся пустое пространство черным цветом. FRAME_RATE — глобальная переменная, в которой задается частота кадров.

Далее нужна функция, которая создаст файл-заглушку для заполнения пропусков между видеофайлами:

write_blank_file() {     local out_file="$1"     [ -e "${out_file}" ] && return;     local duration=$(echo $2 | LC_NUMERIC="C" awk '{printf("%.3f", $1 / 1000)}')     local width="$3"     local height="$4"     ffmpeg -f lavfi -i "color=c=black:s=${width}x${height}:d=${duration}" -c:v vp8 -r:v ${FRAME_RATE} -f lavfi -i "aevalsrc=0|0:d=${duration}:s=48k" -c:a libvorbis -q:a 0 "${out_file}" }

Здесь создается видеодорожка заданного разрешения, продолжительности (в миллисекундах) и частоты кадров, а также звуковая дорожка с тишиной. Все это кодируется теме же кодеками, что и основные видеофрагменты.

Получившиеся видеофрагменты каждой камеры нужно объединить, для этого используется следующая функция (OUTPUT_DIR — глобальная переменная, содержащая путь к директории с видеофрагментами):

concat_video_group() {     local video_group="$1"     ffmpeg -f concat -i <(ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | xargs -I FILE echo "file ${OUTPUT_DIR%/}/FILE") -c copy "${OUTPUT_DIR}/${video_group}"     ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | xargs -I FILE rm "${OUTPUT_DIR%/}/FILE" }

Также понадобится функция для определения продолжительности видеофайла в миллисекундах, здесь используется утилита ffprobe из пакета ffmpeg:

get_video_duration() {     local in_file="$1"     ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${in_file}" | LC_NUMERIC="C" awk '{printf("%.0f", $1 * 1000)}' }

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

  1. Получить список файлов с видеофрагментами, отсортированный с учетом их временной метки, которая составляет первую часть имени файла.
  2. Просмотреть список сверху вниз, попутно создавая другой список вида “отметка_времени: флаг: имя_файла”. Суть этого списка — отметить все точки начала и окончания каждого видеофайла (см. картинку с иллюстрацией фрагментации видеозаписей). Для нашего примера это будет следующий список:
    1:1:camera1-session.webm 3:-1:camera1-session.webm 7:1:camera1-session.webm 10:-1:camera1-session.webm 2:1:camera2-session.webm 5:-1:camera2-session.webm 8:1:camera2-session.webm 10:-1:camera2-session.webm 3:1:screen-session.webm 6:-1:screen-session.webm 8:1:screen-session.webm 12:-1:screen-session.webm
  3. Полученный список дополнить записями с нулевой продолжительностью (одинаковыми отметками времени) для первого и последнего файла исходного списка видеофрагментов. Это понадобится на этапе расчета недостающих промежуточных видеофрагментов.
  4. Дополнить полученный список записями, которые соответствуют началу и окончанию фрагментов, когда нет видео ни с одной из камер. В нашем примере это будут записи “6:1:…” и “7:-1:…”.
  5. Полученный список разбить на три части, получаем для каждой камеры свой список. Пройтись по каждому списку и инвертировать его, т.е. вместо списка существующих фрагментов должен получиться список недостающих фрагментов.
  6. Преобразовать полученный список к формату “отметка_времени: продолжительность: имя_файла”, чтобы на основе него можно было создать недостающие видеофрагменты.

Данный алгоритм реализуется следующим набором функций:

# преобразование меток # input: timestamp:flag:filename # output: timestamp:duration:filename find_spaces() {     local state=0 prev=0     sort -n | while read item     do         arr=(${item//:/ })         timestamp=${arr[0]}         flag=${arr[1]}         let state=state+flag         if [ ${state} -eq 0 ]         then             let prev=timestamp         elif [ ${prev} -gt 0 ]         then             let duration=timestamp-prev             if [ ${duration} -gt 0 ]             then                 echo ${prev}:${duration}:${arr[2]}             fi             prev=0         fi     done } # добавление первой и последней метки с нулевой продолжительностью zero_marks() {     sort -n | sed '1!{$!d}' | while read item     do         arr=(${item//:/ })         timestamp=${arr[0]}         for video_group in ${VIDEO_GROUPS}         do             echo ${timestamp}:1:${video_group}             echo ${timestamp}:-1:${video_group}         done     done } # добавить фрагменты, на которых нет видео ни с одной камеры blank_marks() {     find_spaces | while read item     do         arr=(${item//:/ })         first_time=${arr[0]}         duration=${arr[1]}         let last_time=first_time+duration         for video_group in ${VIDEO_GROUPS}         do             echo ${first_time}:1:${video_group}             echo ${last_time}:-1:${video_group}         done     done } # генерирование меток в формате: timestamp:duration:filename generate_marks() {     ls "${OUTPUT_DIR}" | grep "^[0-9]\+_" | sort -n | while read video_file     do         filename=${video_file#*_}         timestamp=${video_file%%_*}         duration=$(get_video_duration "${OUTPUT_DIR%/}/${video_file}")         echo ${timestamp}:1:${filename}         echo $((timestamp+duration)):-1:${filename}     done | tee >(zero_marks) >(blank_marks) } # поиск фрагментов по каждой камере, на которых нет видео fragments_by_groups() {     local cmd="tee"     for video_group in ${VIDEO_GROUPS}     do         cmd="${cmd} >(grep :${video_group}$ | find_spaces)"     done     eval "${cmd} >/dev/null" } # запись недостающих видеофрагментов write_fragments() {     while read item     do         arr=(${item//:/ })         timestamp=${arr[0]}         duration=${arr[1]}         video_file=${arr[2]}         write_blank_file "${OUTPUT_DIR%/}/${timestamp}_${video_file}" "${duration}" $(get_video_resolution "${video_file}")     done } # воссоздать недостающие видеофрагменты generate_marks | fragments_by_groups | write_fragments

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

concat_video_group() {     local video_group="$1"     ffmpeg -f concat -i <(ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | sort -n | xargs -I FILE echo "file ${OUTPUT_DIR%/}/FILE") -c copy "${OUTPUT_DIR}/${video_group}" }

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

encode_video_complex() {     local video_file="$1"     local camera1="$2"     local camera2="$3"     local camera3="$4"     ffmpeg \         -i "${OUTPUT_DIR%/}/${camera1}" \         -i "${OUTPUT_DIR%/}/${camera2}" \         -i "${OUTPUT_DIR%/}/${camera3}" \         -threads ${NCPU} -c:v vp8 -r:v ${FRAME_RATE} -c:a libvorbis -q:a 0 \         -filter_complex "             pad=1088:480 [base];             [0:v] setpts=PTS-STARTPTS, scale=320:240 [camera1];             [1:v] setpts=PTS-STARTPTS, scale=320:240 [camera2];             [2:v] setpts=PTS-STARTPTS, scale=768:480 [camera3];             [base][camera1] overlay=x=0:y=0 [tmp1];             [tmp1][camera2] overlay=x=0:y=240 [tmp2];             [tmp2][camera3] overlay=x=320:y=0;             [0:a][1:a] amix" "${OUTPUT_DIR%/}/${video_file}" }

Здесь с помощью фильтра ffmpeg создается пустая область черного цвета (pad), затем на ней размещаются в заданном порядке камеры. Звук с первых двух камер микшируется.

После обработки видео и получения выходного файла, закачаем его на сервер (глобальные переменные STORAGE_URL, STORAGE_USER и STORAGE_PASS содержат адрес сервера WebDAV, имя пользователя и пароль к нему соответственно):

upload() {     local video_file="$1"     [ -n "${video_file}" ] || return 1     [ -z "${STORAGE_URL}" ] && return 0     local http_code=$(curl -o /dev/null -w "%{http_code}" --digest --user ${STORAGE_USER}:${STORAGE_PASS} -T "${OUTPUT_DIR%/}/${video_file}" "${STORAGE_URL%/}/${video_file}")     # если файл создан, то код ответа 201, если обновлен - 204     test "${http_code}" = "201" -o "${http_code}" = "204" }

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

#!/bin/bash STORAGE_DIR="./storage" write_blank_video() {     local width="$1"     local height="$2"     local color="$3"     local duration="$4"     local frequency="$5"     local out_file="$6-56a8a7e3f9adc29c4dd74295.webm"     ffmpeg -y -f lavfi -i "color=c=${color}:s=${width}x${height}:d=${duration}" -f lavfi -i "sine=frequency=${frequency}:duration=${duration}:sample_rate=48000,pan=stereo|c0=c0|c1=c0" -c:a libvorbis -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf: timecode='00\:00\:00\:00': r=30: x=10: y=10: fontsize=24: fontcolor=black: box=1: boxcolor=white@0.7" -c:v vp8 -r:v 30 "${STORAGE_DIR%/}/${out_file}" </dev/null >/dev/null } # camera1 write_blank_video 320 200 blue 2 1000 1000_camera1 write_blank_video 320 200 blue 3 1000 7000_camera1 # camera2 write_blank_video 320 240 green 3 2000 2000_camera2 write_blank_video 320 240 green 2 2000 8000_camera2 # screen write_blank_video 800 480 red 3 3000 3000_screen write_blank_video 800 480 red 4 3000 8000_screen


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

ссылка на оригинал статьи https://habrahabr.ru/post/277179/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *