Привет, Хабр! В прошлой части я рассказал, как автоматизировать простую нарезку YouTube-видео на Shorts, добавить туда текст и размытый фон. Сегодня займемся более комплексной задачей — генерацией вертикальных видео на основе записи с геймплеем и текстом. В тексте узнаете, как генерировать аудио с помощью библиотеки Bark и настроить анимацию ASCII-маскота. Подробнее — под катом.
Используйте навигацию, чтобы выбрать интересующий блок
→ Постановка задачи
→ Конвертация и кадрирование
→ Генерация аудио
→ Генерация маскота
→ Создание удобного UI
→ Итоги
Постановка задачи
Начнем с вводных данных. Моя главная цель — начать уделять меньше времени на продвижение игры. Для этого разработал небольшой проект по автоматизации монтажа видео для YouTube Shorts. Игра базируется на хакерской эстетике. Поэтому чтобы погрузить аудиторию в эту атмосферу, решил использовать роботизированный голос для озвучивания видео и ASCII-маскот, который
Внешний вид ASCII-маскота.
Мне хотелось оставить в видео процесс управления персонажем, но обилие текста сильно перегружало вертикальный формат. Поскольку консоль — обязательная часть пользовательского интерфейса, я ограничил ее от остальных элементов, расположил сверху экрана и увеличил в размере, чтобы можно было рассмотреть код.
Резюмируем, что должен уметь конечный скрипт.
- Конвертировать и кадрировать горизонтальные видео в вертикальные.
- Генерировать аудио на основе текста, а также добавлять звуковые эффекты для роботизации голоса.
- Генерировать анимации маскота, привязанного к аудиофайлу.
В качестве дополнительного инструмента выбрал Python. В нем много TTS-решений (text-to-speech), которые помогают конвертировать текст в разговорную речь.
Конвертация и кадрирование
В прошлом материале мы выяснили, что процесс не представляет собой ничего сложного — реализовать его можно одной FFMPEG-командой. Поэтому не буду описывать все параметры FFMPEG-команды, чтобы не повторяться. Если хотите узнать о ней подробнее, переходите по ссылке.
if [[ -z $3 ]]; then
start_time="0"
fi
if [[ $no_console -ne 1 ]]; then
console="[3:v]scale=1080*4:-1, crop=in_w:200:0:100, trim=start=$start_time, setpts=PTS-STARTPTS[console];\
[mix][console]overlay=0:0[mix];"
else
console=""
fi
ffmpeg -i $background -i $video -filter_complex \
"[0:v]scale=1080:1920[bg]; \
[1:v]scale=1080*2:-1, trim=start=$start_time, crop=in_w:in_h-300, setpts=PTS-STARTPTS[vid]; \
[1:a]volume=0.1,atrim=start=$start_time[audio];\
[bg][vid]overlay=(W-w)/2:0[mix];$console" \
-map [mix] -map [audio] -r 60 output.mp4 -y
В скрипте можно настроить отображение консоли сверху. Если вам такая функция не нужна, используйте параметр $no_console, и она не будет отображаться.
Также можем подставлять в видео не только геймплей, но и другую запись. Пригодится, если нужно будет сделать информационный ролик без футажей игры.
Генерация аудио
У Python есть много TTS-библиотек, но большинство из них меня не устроили. Мне нужен был женский голос, а качественных решений, оказывается, было довольно мало.
Позже я нашел библиотеку Bark — мне понравилась поэтичность и точность названия. Она не просто роботизированно воспроизводит текст, но и добавляет паузы, вздохи, смешки и другие нюансы, которые оживляют голос. Я не проверял, умеет ли голос лаять, но не удивился, если бы и такая возможность была.
Поскольку проект комплексный, я разделил его на несколько файлов. У каждого была только одна задача. В результате у меня получилось упростить процесс написания и тестирования кода.
Создаем небольшой файл genwav.py для генерации нашей аудиодорожки. Стоит отметить, что библиотека адекватно работает только с аудио длительностью не более 13 секунд. Однако в документации написано, как эту проблему можно обойти. Разбиваем текст на отдельные предложения и склеиваем их в один файл. Вот что у меня получилось в итоге:
import os
import torch
os.environ["SUNO_ENABLE_MPS"] = "True"
torch.device("mps")
import nltk
nltk.download('punkt')
import numpy as np
from bark.generation import (
generate_text_semantic,
preload_models,
)
from bark.api import semantic_to_waveform
from bark import generate_audio, SAMPLE_RATE
import sys
input = sys.argv[1]
with open(input,'r') as i:
text = i.read()
sentences = nltk.sent_tokenize(text)
from transformers import AutoProcessor, BarkModel
processor = AutoProcessor.from_pretrained("suno/bark")
model = BarkModel.from_pretrained("suno/bark")
GEN_TEMP = 0.8
SPEAKER = "v2/en_speaker_9"
pieces = []
for sentence in sentences:
semantic_tokens = generate_text_semantic(
sentence,
history_prompt=SPEAKER,
temp=GEN_TEMP,
min_eos_p=0.05,
)
audio_array = semantic_to_waveform(semantic_tokens, history_prompt=SPEAKER,)
pieces += [audio_array]
result = np.concatenate(pieces)
import scipy
scipy.io.wavfile.write("temp/voice.wav", rate=int(SAMPLE_RATE), data=result)
Мне нужен был именно женский голос, поэтому я выбрал модель v2/en_speaker_9. Обратите внимание на частоту дискретизации, результирующей WAV-формат. Она равна 24 кГц. Это значение пригодится нам в будущем.
В самом начале я включил опцию использования MPS (Metal Performance Shaders), чтобы ускорить процесс генерации. Опция актуальна только для MacBook с процессорами Apple Silicon.
В результате генерации у аудио появились большие паузы между предложениями. Вырезать их я решил здесь же — не хотелось усложнять и без того большую цепочку скриптов. Учитывая, что у меня еще сырой поток не превращенных в WAV байтов, вырезать паузы можно еще быстрее.
// Генерация голоса
ZEROS_TO_REMOVE=10000
index_of_first_zero=-1
zero_counter=0
for i in range(len(result)):
if abs(result[i]) < 0.005:
if index_of_first_zero == -1:
index_of_first_zero = i;
zero_counter= zero_counter+1;
if zero_counter > ZEROS_TO_REMOVE:
result[index_of_first_zero:i] = 0.;
index_of_first_zero=i;
else:
zero_counter=0;
index_of_first_zero=-1;
result=result[result!=0]
import scipy
scipy.io.wavfile.write("temp/voice.wav", rate=int(SAMPLE_RATE), data=result)
Аудио состоит из массива чисел от -1 до 1. Все значения меньше 0.005 по модулю достаточно тихие, чтобы считать их тишиной. Поэтому превращаю их в 0, после чего отфильтровываю.
Срабатывает изменение только в том случае, когда подобных значений 10 тысяч или больше. Это равносильно тишине чуть меньше 0,5 секунды (24 кГц равняется 24 000 значений в секунду). Конечно, алгоритм можно оптимизировать, но раз он справляется со своей задачей, то:
После — добавляем в существующий Shell-скрипт наше аудио. Но даже здесь все обходится не без нюансов. Подробнее о них рассказываю ниже.
vid_len=$(ffprobe -v error -select_streams v:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 $video)
sound_len=$(ffprobe -v error -select_streams a:0 -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 $audio)
vid_len=$(echo $vid_len $start_time | awk '{print $1 - $2}')
duration=$(python -c "print(min($vid_len,$sound_len))")
if [[ $mute_video -ne 1 ]]; then
vid_audio="[1:a]volume=0.1,atrim=start=$start_time[avid];\
[audio][avid]amix=2[audio];"
else
vid_audio=""
fi
ffmpeg -i $background -i $video -i $audio -filter_complex \
"[0:v]scale=1080:1920[bg]; \
[1:v]scale=1080*2:-1, trim=start=$start_time, crop=in_w:in_h-300, setpts=PTS-STARTPTS[vid]; \
[2:a]volume=1.0,\
tremolo=f=500:d=0.1,\
chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3,\
rubberband=pitch=1.1\
[audio];$vid_audio\
[bg][vid]overlay=(W-w)/2:0[mix];$console" \
-map [mix] -map [audio] -t $duration -r 60 output.mp4 -y
Чтобы избежать лишней работы с установкой длительности конечного видео, настраиваю скрипт так, чтобы видео выбиралось автоматически — на основе длины аудио и видео, а именно кратчайшее из них.
Далее добавляю возможность убирать аудио в исходном видео — пригодится для некоторых сценариев. Также накладываю на аудио эффекты тремоло, хоруса и питч-коррекции, чтобы сделать звук более роботизированным.
Да, я искал библиотеку с реалистичным голосом, чтобы потом его роботизировать.
По сути этого могло быть достаточно, но хотелось чего-то эдакого, поэтому приступил к добавлению анимированного маскота.
Генерация маскота
Для начала уточню, из чего состоит анимация. Подобную логику я реализовывал в игре, но без анализа аудиофайла.
- IDLE-анимация. Классическая история для видеоигр, в которых персонаж двигается вниз-вверх.
- Моргание ASCII-маскота.
- Открывание рта в такт сгенерированному голосу.
С первыми двумя пунктами в целом понятно: их можно реализовать в рамках вышеописанного скрипта. А для последнего потребуется немного подготовительной работы. Добавим в новый Python-скрипт файл gensylltim.py для рассчитывания тайминга слогов, который поможет понять, когда маскоту нужно открыть рот.
import sys
audio = sys.argv[1]
import numpy as np
from scipy.io.wavfile import read
import wave
frame_rate = 24000
a = read(audio)
arr = np.array(a[1],dtype=float)
import scipy.signal
indexes, _ = scipy.signal.find_peaks(arr, height=0.05, distance=frame_rate/4)
result=map(lambda it: it/frame_rate, indexes)
print(list(result))
Скрипт ищет пиковые значения в массиве байтов WAV-файла, используя библиотеку SciPy. После — возвращает результат в потоке stdout. Далее буду «ловить» его с помощью утилиты grep в Shell-скрипте.
Здесь пригодилось значение частоты дискретизации — при поиске пиковых значений в нашем массиве громкостей, а также при конвертации этих значений в тайминги.
Значения height=0.05 и distance=frame_rate/4 были подобраны путем научного тыка. В случае использования модели отличной от моей, значения могут отличаться.
Теперь перейдем к генерации состояний для нашего маскота. Анимация будет состоять из 10 FPS, IDLE-анимация — из 6 FPS на каждое состояние (12 кадров в итоге), моргание — 33 FPS (три кадра на закрытые глаза и 30 на открытые). За счет такого рассинхрона анимация выглядит живее, при этом никак не влияет на реализацию. В конце первого скрипта добавляю фрагмент кода:
loop_time=$(echo "$vid_len * 10" | bc -l)
loop_time=${loop_time%.*}
function get_states()
{
local i=0
states=()
local timer=0
local current_state=0
while [[ $i -le $loop_time ]]; do
states+=("$current_state")
((timer = timer + 1))
if [[ -z $2 ]]; then
if [[ $timer -ge $1 ]]; then
timer=0
current_state=$((current_state ^= 1));
fi
else
if ([ $current_state == 0 ] && [ $timer -ge $1 ]) || ([ $current_state == 1 ] && [ $timer -ge $2 ]); then
timer=0
current_state=$((current_state ^= 1));
fi
fi
((i = i + 1))
done
}
readonly TOP_TIME=6
get_states $TOP_TIME
top_states=("${states[@]}")
readonly CE_TIME=3
readonly OE_TIME=30
get_states $OE_TIME $CE_TIME
eyes_states=("${states[@]}")
syll_timings=($(python gensylltim.py $2 | tr -d '[],'))
Здесь я генерирую массивы состояний для каждого кадра и подтягиваю тайминги слогов, используя ранее написанный скрипт. Код выходит уже не самый красивый и лаконичный, но не переживайте — самое страшное будет дальше:
toc_states="between(t,-1,-1)"
boc_states="between(t,-1,-1)"
tcc_states="between(t,-1,-1)"
bcc_states="between(t,-1,-1)"
tco_states="between(t,-1,-1)"
bco_states="between(t,-1,-1)"
too_states="between(t,-1,-1)"
boo_states="between(t,-1,-1)"
current_time=0
for i in ${!top_states[@]}; do
prev_time_sec=$(echo $current_time | awk '{printf "%.2f", $1 / 10}')
((current_time = current_time + 1))
current_time_sec=$(echo $current_time | awk '{printf "%.2f", $1 / 10}')
between="+between(t,$prev_time_sec,$current_time_sec)"
syll_condition=0
if [[ ${#syll_timings} -ne 0 ]]; then
syll_condition=$(echo "scale=2; ${syll_timings[0]} <= ($current_time_sec + 0.1)" | bc)
if [[ $syll_condition -eq 1 ]]; then
syll_timings=("${syll_timings[@]:1}")
fi
fi
if [[ ${top_states[$i]} -eq 0 ]]; then
if [[ ${eyes_states[$i]} -eq 0 ]]; then
if [[ $syll_condition -eq 1 ]]; then
too_states+=$between
else
toc_states+=$between
fi
else
if [[ $syll_condition -eq 1 ]]; then
tco_states+=$between
else
tcc_states+=$between
fi
fi
else
if [[ ${eyes_states[$i]} -eq 0 ]]; then
if [[ $syll_condition -eq 1 ]]; then
boo_states+=$between
else
boc_states+=$between
fi
else
if [[ $syll_condition -eq 1 ]]; then
bco_states+=$between
else
bcc_states+=$between
fi
fi
fi
done
Сниппет генерирует строки between, которые будут говорить FFMPEG, когда и какую картинку показывать. Сейчас выглядит он не самым оптимальным образом, поскольку опция работает для каждой 0,1 секунды, когда появляется изображение. Но на перформансе это не отражается, поэтому в оптимизации алгоритма смысла не вижу.
Наконец, внедряем это в FFMPEG.
tcc="loda/Loda,Default,Top,Eclose,MClose.png"
tco="loda/Loda,Default,Top,Eclose,MOpen.png"
toc="loda/Loda,Default,Top,Eopen,MClose.png"
too="loda/Loda,Default,Top,Eopen,MOpen.png"
bcc="loda/Loda,Default,Bottom,Eclose,MClose.png"
bco="loda/Loda,Default,Bottom,Eclose,MOpen.png"
boc="loda/Loda,Default,Bottom,Eopen,MClose.png"
boo="loda/Loda,Default,Bottom,Eopen,MOpen.png"
ffmpeg $quiet -i "temp/output.mp4" \
-i $tcc -i $tco -i $toc -i $too \
-i $bcc -i $bco -i $boc -i $boo \
-filter_complex \
"[1:v]scale=-1:800[tcc];\
[2:v]scale=-1:800[tco];\
[3:v]scale=-1:800[toc];\
[4:v]scale=-1:800[too];\
[5:v]scale=-1:800[bcc];\
[6:v]scale=-1:800[bco];\
[7:v]scale=-1:800[boc];\
[8:v]scale=-1:800[boo];\
[0:v][tcc]overlay=(W-w)/2:(H-h):enable='$tcc_states'[temp];\
[temp][tco]overlay=(W-w)/2:(H-h):enable='$tco_states'[temp];\
[temp][toc]overlay=(W-w)/2:(H-h):enable='$toc_states'[temp];\
[temp][too]overlay=(W-w)/2:(H-h):enable='$too_states'[temp];\
[temp][bcc]overlay=(W-w)/2:(H-h):enable='$bcc_states'[temp];\
[temp][bco]overlay=(W-w)/2:(H-h):enable='$bco_states'[temp];\
[temp][boc]overlay=(W-w)/2:(H-h):enable='$boc_states'[temp];\
[temp][boo]overlay=(W-w)/2:(H-h):enable='$boo_states'[res];\
[0:a]volume=1.0[audio];"\
-map [res] -map [audio] -r 60 "fin.mp4"
Закидываем на вход ранее сгенерированное видео, а также картинки для каждого состояния. Накладываем их в нижнюю часть экрана и с помощью параметра enable проставляем моменты, в которых будем показывать нужные. Готово!
Результат.
Создание удобного UI
Сейчас у нас есть несколько скриптов. Чтобы упростить их использование, я написал еще один:
while getopts ':v:s:t:mc' flag; do
case $flag in
v)
vid=$OPTARG
;;
s)
st=$OPTARG
;;
t)
text=$OPTARG
;;
m)
mute=1
;;
c)
console=1
;;
\?)
continue
;;
esac
done
if [[ -z $vid ]]; then
echo "You need to pass video using -v flag, you can also set text using -t and start time using -s"
exit
fi
set -e
dir="$HOME/tools/TikTok"
source $dir/.env/bin/activate
mkdir -p $dir/temp
if [[ "$text" != "" ]]; then
python $dir/genwav.py $text
open $dir/temp/voice.wav
read -r -p "Do you like the result? [y/N] " response
if ! [[ "$response" =~ ^([yY])$ ]]; then
exit
fi
fi
sh $dir/genvid.sh $vid $dir/temp/voice.wav $st $console $mute
deactivate
open .
В нем указываю входные параметры с помощью флагов:
- v — видео,
- t — файл с текстом (если его опустить, скрипт начнет использовать ранее сгенерированный звук),
- s — начало видео (опционально),
- m — заглушить оригинальное видео (опционально),
- с — спрятать консоль (опционально).
После генерации система начинает автоматически проигрывать аудио и, если результат устраивает, то продолжает свою работу. В конце открывается папка со сгенерированным видео, чтобы можно было его загрузить в соц. сети.
Итоги
Несмотря на то, что удалось значительно ускорить процесс создания вертикальных видео, требуется некоторое время на генерацию аудио. Однако это намного быстрее, чем создавать, обрезать и настраивать видео руками. Запускаешь генерацию и занимаешься своими делами — мечта инженера!
Также хочу отметить пользу работы над проектом лично для меня. Наконец, я более или менее разобрался с Bash, продвинулся в понимании работы FFMPEG, а также познакомился с библиотекой Bark, которая в будущем мне может пригодится.
Надеюсь, вам тоже был интересен мой опыт. Буду рад вашему мнению в комментариях.
EugeneH
В Bark еще можно генерировать/клонировать голоса. Очень легко сделать голос по своему вкусу вместо стандартного встроенного.
Плюс, существует фреймворк Bark Infinity с автоматической нарезкой длинного текста и другими плюшками.
Мне не хватало только возможности стримить аудио с низкой задержкой (барк хоть и работает быстрее, чем в реальном времени, но приходится ждать пока будет готов кусок в 11-13 секунд). Поэтому переключился на Coqui TTSv2, там стриминг из коробки работает.
fellow_pablo Автор
Спасибо!
Может есть ещё рекомендации по клонированию голоса + переводу на другой язык? Очень интересно подобное решение (видел платные сервисы только, хотя глубокий ресерч ещё не производил).
EugeneH
Ничего локально запускаемого, чтобы было на уровне HeyGen не знаю.
Такая же беда с генерацией музыки.
fellow_pablo Автор
Ну значит подождём ещё полгодика)
Рано или поздно что-нибудь достойное окажется в опенсорсе.