Привет! В прошлой части я рассказал, как автоматизировать простую нарезку 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;
[bg][vid]overlay=(W-w)/2:0[mix];$console"
-map [mix] -map -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];
[avid]amix=2;"
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
;$vid_audio
[bg][vid]overlay=(W-w)/2:0[mix];$console"
-map [mix] -map -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;"
-map [res] -map -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, которая в будущем мне может пригодится.
Надеюсь, вам тоже был интересен мой опыт. Буду рад вашему мнению в комментариях.
Автор: fellow_pablo