
Привет, Хабр! В прошлой части я рассказал, как автоматизировать простую нарезку 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, которая в будущем мне м��жет пригодится.
Надеюсь, вам тоже был интересен мой опыт. Буду рад вашему мнению в комментариях.
