В системе дистанционного надзора (СДН), обзор которой был сделан в предыдущей статье, для управления медиапотоками используется медиасервер 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: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
- Полученный список дополнить записями с нулевой продолжительностью (одинаковыми отметками времени) для первого и последнего файла исходного списка видеофрагментов. Это понадобится на этапе расчета недостающих промежуточных видеофрагментов.
- Дополнить полученный список записями, которые соответствуют началу и окончанию фрагментов, когда нет видео ни с одной из камер. В нашем примере это будут записи “6:1:…” и “7:-1:…”.
- Полученный список разбить на три части, получаем для каждой камеры свой список. Пройтись по каждому списку и инвертировать его, т.е. вместо списка существующих фрагментов должен получиться список недостающих фрагментов.
- Преобразовать полученный список к формату “отметка_времени: продолжительность: имя_файла”, чтобы на основе него можно было создать недостающие видеофрагменты.
Данный алгоритм реализуется следующим набором функций:
# преобразование меток # 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/
Добавить комментарий