YouTube Shorts из терминала. Как автоматизировать создание видео с помощью FFMPEG и Bash. Часть 2

от автора

Привет, Хабр! В прошлой части я рассказал, как автоматизировать простую нарезку YouTube-видео на Shorts, добавить туда текст и размытый фон. Сегодня займемся более комплексной задачей — генерацией вертикальных видео на основе записи с геймплеем и текстом. В тексте узнаете, как генерировать аудио с помощью библиотеки Bark и настроить анимацию ASCII-маскота. Подробнее — под катом.

Используйте навигацию, чтобы выбрать интересующий блок

Постановка задачи
Конвертация и кадрирование
Генерация аудио
Генерация маскота
Создание удобного UI
Итоги

Постановка задачи


Начнем с вводных данных. Моя главная цель — начать уделять меньше времени на продвижение игры. Для этого разработал небольшой проект по автоматизации монтажа видео для YouTube Shorts. Игра базируется на хакерской эстетике. Поэтому чтобы погрузить аудиторию в эту атмосферу, решил использовать роботизированный голос для озвучивания видео и ASCII-маскот, который говорит открывает рот в такт текста.

Внешний вид ASCII-маскота.

Мне хотелось оставить в видео процесс управления персонажем, но обилие текста сильно перегружало вертикальный формат. Поскольку консоль — обязательная часть пользовательского интерфейса, я ограничил ее от остальных элементов, расположил сверху экрана и увеличил в размере, чтобы можно было рассмотреть код.

Резюмируем, что должен уметь конечный скрипт.

  1. Конвертировать и кадрировать горизонтальные видео в вертикальные.
  2. Генерировать аудио на основе текста, а также добавлять звуковые эффекты для роботизации голоса.
  3. Генерировать анимации маскота, привязанного к аудиофайлу.

В качестве дополнительного инструмента выбрал 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, которая в будущем мне может пригодится.

Надеюсь, вам тоже был интересен мой опыт. Буду рад вашему мнению в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/813767/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *