В системе дистанционного надзора (СДН), обзор которой был сделан в предыдущей статье, для управления медиапотоками используется медиасервер 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 это некие временные метки; жирная линия — это видеофрагменты различной продолжительности; пунктирная линия — недостающие фрагменты, которые нужно добавить; пустые промежутки — интервалы времени, в которых нет никаких видеофрагментов, должны быть исключены из итоговой видеозаписи.
Выходной видеофайл представляет собой блок из трех частей, две камеры с разрешением 320x240 (4:3) и один экран с разрешением 768x480 (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-сервер можно удалять исходные файлы, таким образом осуществляется архивирование видео для последующего просмотра в удобочитаемом виде.
Автор: meefik