Мы небольшая студия, делаем экономическую стратегию про IT-компанию на Godot 4. Для соцсетей нужно много коротких вертикальных роликов, а монтировать каждый руками долго и скучно. В итоге собрали конвейер: игра сама разыгрывает сцену и пишет видео, а скрипт на Python монтирует из этого шортс с подписями, голосом, музыкой и обложкой. Один ролик собирается примерно за минуту, сейчас их 64. Расскажу, как это устроено и на какие грабли наступили.
Запись: Movie Maker в Godot
В Godot 4 есть режим Movie Maker. Если запустить проект с ключом --write-movie, движок пишет каждый кадр в файл с фиксированным шагом времени, а не в реальном времени. Запись не зависит от того, успевает ли компьютер: ролик в 60 кадров в секунду получается ровным, даже если тяжёлый кадр рендерится 100 мс.
godot --path game -s res://tools/short_story.gd --fixed-fps 60 --write-movie story/friday.avi
Вертикальное окно получили через override.cfg. Если рядом с проектом лежит этот файл, Godot при старте берёт из него настройки поверх project.godot. Скрипт кладёт файл, запускает запись и удаляет его:
[display] window/size/viewport_width=1080 window/size/viewport_height=1920 window/size/window_width_override=540 window/size/window_height_override=960 window/stretch/mode="viewport"
Окно на экране 540×960, а кадр пишется 1080×1920. Удалять файл нужно обязательно в finally: однажды он остался в папке после упавшей записи, и следующая сборка игры сломалась на копировании проекта. Теперь сборщик демо этот файл пропускает, а скрипт записи отказывается стартовать, если override.cfg уже лежит рядом и он его не создавал.
Movie Maker пишет AVI с MJPEG, файлы большие: 30 секунд записи весят около 250 МБ. Это только исходник, дальше всё пережимается.
Постановка сцены
short_story.gd запускается как скрипт SceneTree (ключ -s). Он начинает партию с заранее подготовленной компанией, расставляет людей, переводит игровое время и ставит камеру. История описана списком кадров: id, длительность и время на подготовку. Подготовка нужна, чтобы сцена успокоилась: подгрузились модели, люди сели за столы, свет пересчитался. Потом это время вырезается.
Рядом с видео скрипт пишет JSON с чистыми отрезками:
[[2.5, 6.9, "day"], [7.7, 11.5, "cat_walk"], [11.9, 21.3, "cat_kb"], [22.1, 25.9, "team"], [26.7, 30.5, "founder"]]
Монтаж дальше работает только с этими кусками и ничего не знает о подготовке.
Чтобы истории можно было ставить, игре пришлось научиться кое-чему по команде: усадить кота на клавиатуру основателя, включить красный свет при сбое, задержать команду в офисе до ночи. Сохранения и настройки игрока при записи не трогаются: партия пишется в отдельную тестовую папку, оформление меняется только в памяти.
Сценарий как данные
Ролик описывается "битами". Бит это один кадр и то, что в нём происходит: фразы закадрового голоса, подпись, уведомления на экране телефона, звуки. Примерно так:
'meeting': { 'folder': '115_sozvon_na_pyat_minut', 'hook': 'Созвон\nна 5 минут', 'beats': [ B('story/friday:calm', 'Созвон на пять минут, сказал менеджер.'), B('story/deadline:eve', 'Прошёл час.', cap=('16:00', 'mid'), pad=1.0), B('story/deadline:late', 'Обсудили, когда обсудим.', cap=('19:00', 'mid')), B('story/deadline:cat_sleep', 'Байт ушёл спать ещё на первой минуте.', grade='dusk'), ], 'music': ('lofi_19.ogg', 3.0, 0.32), }
Длительность кадра не задаётся руками, а считается от длины голоса: вступление, фразы с паузой 0,7 с между ними и 0,75 с после. Перед финальной карточкой пауза дольше, 1,3 с, иначе переход получался резким. Если записи кадра не хватает под голос, монтаж сначала сдвигает начало куска, а если и этого мало, замедляет кадр, но не больше чем до 0,7×.
Голос
Голос синтезируется локально: Silero TTS v5 для русского языка, PyTorch на процессоре. Паузы и интонацию задаём SSML: <break time="100ms"/>, <prosody pitch="high">. Каждая фраза кэшируется в WAV, имя файла это md5 от текста, голоса и скорости. Повторная сборка ролика с теми же фразами ничего не синтезирует заново, а правка одной фразы пересчитывает только её. На фразу уходит около секунды.
После синтеза голос проходит через фильтры ffmpeg: шумодав, деэссер, лёгкий эквалайзер и короткое эхо, чтобы он звучал не так стерильно.
Монтаж
Каждый кусок вырезается отдельной командой ffmpeg с наездом камеры. Наезд задаётся выражением, которое считается на каждом кадре:
fps=30,scale=1080:1920, scale=w='trunc(iw*(1+(1.08-1)*t/2.4)/2)*2':h='trunc(ih*(1+(1.08-1)*t/2.4)/2)*2':eval=frame, crop=1080:1920:x='(iw-1080)/2':y='(ih-1920)/2'
На ударных моментах вместо плавного наезда стоит резкий за 0,12 с и пара толчков камеры, которые тоже собираются из выражений с min и max от времени. Цветовые настроения ("вечер", "тревога") это обычные eq, colorbalance и vignette.
Подписи рисует PIL: белый жирный текст с обводкой, он читается на любом фоне. Каждая подпись это отдельный PNG, который накладывается overlay с enable='between(t,a,b)'. Сообщения рабочего чата и уведомления на телефоне рисуются так же. Всё это собирается в один filter_complex_script: в командную строку он не помещается.
Есть безопасная зона. Сверху около 250 пикселей закрывает строка площадки, снизу около 420 пикселей занимают подпись, кнопки и описание. Всё важное держим между ними.
Музыка режется кусками под настроение ролика: спокойно, тревога, снова спокойно. Звуки игры взяты из бесплатных наборов Kenney (CC0).
Обложка и тексты
В конце скрипт берёт кадр из ролика, кладёт на него крупный заголовок и сохраняет обложку. Рядом пишет название, описание, закреплённый комментарий и хештеги в отдельные файлы. В ссылке на сайт стоят метки с номером ролика, чтобы потом было видно, какой именно ролик привёл людей.
Что получилось
64 сюжетных ролика. Запись одной истории занимает пару минут, монтаж ролика около минуты.
Ролик можно пересобрать после любого изменения в игре. Когда мы поменяли маскота и финальную карточку, все ролики пересобрали одной командой.
Первая сотня роликов была собрана генератором по шаблону: одни и те же планы, одинаковые подписи. Смотрелось скучно. Сейчас сценарий каждой истории пишет человек, а машина делает только монтаж.
Площадки не дают загрузить через наш инструмент файл больше 10 МБ, поэтому для выкладки ролики дополнительно пережимаются двухпроходным x264 примерно до 9 МБ.
Если интересно посмотреть, что получается в итоге: игра называется Котопорация, у неё есть бесплатное демо (granikogame.ru). Код конвейера могу показать подробнее, если будут вопросы в комментариях.
