javascript

Про то, как я научил своего аватара снимать ролики по сценарию без ИИ-генерации видео (часть 2)

  • вторник, 22 сентября 2026 г. в 00:00:08
https://habr.com/ru/articles/1083540/

NB. Это дневник почти двух месяцев разработки. Здесь будет много кода, неудачных экспериментов, технических решений и анимированных примеров. Одно изменение постоянно тянуло за собой другое, поэтому получилось длинно. Для удобства разбил историю на главы — можно переходить сразу к интересующим задачам.
Приятного чтения!

Оглавление
  1. Вступление

  2. Мельтешение рта или как я переделал артикуляцию

  3. Ручной фон слишком долго делать или зачем мне понадобились генераторы

  4. Правка сценария затирала весь проект или как я перестал терять изменения

  5. Проблема пустого фона или как появился Flow-режим

  6. Когда одних слов мало или как появились эмодзи в Flow-режиме

  7. GIF в Flow-тексте или зачем понадобился FLOW_MEDIA

  8. Flow и слайды не связаны между собой или как появился протослайд

  9. Несколько слайдов подряд или как собрать цельную историю

  10. Переходы без новых функций в коде или как расширять библиотеку эффектов

  11. Ведущему не с кем спорить или как появился робот аЙгорь

  12. Один голос на ведущего и робота или как обработать речь через FFmpeg

  13. Осознанное пищание робота или как появился звуковой язык аЙгоря

  14. Добавление новых героев без переписывания кода или как появилась система Character Packs

  15. Два героя в кадре или как понять кто говорит, а кто слушает

  16. Управление сценой или как вводить героев в кадр

  17. Два голоса на один Flow или как разделить реплики героев цветом

  18. Стресс-тест роликом на 4 минуты или как выдержать 50 пересборок сценария

  19. Сложный движок с простым UI или как уместить всё в четыре кнопки

  20. ChatGPT-помощник или как запомнить все теги

  21. Движок исполнит любую глупость автора или как монтаж становится режиссурой

  22. Общий визуал проекта или как собрать разные ролики в одном стиле

  23. Аналитика Ютуба или что показали реальные публикации

  24. Ограничения вертикального формата или что нужно менять для горизонтали

  25. Выход за рамки блога или справится ли движок с чужой задачей


Вступление

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

На первой итерации всё выглядело как победа: 30-секундный ролик собирался заметно быстрее, чем вручную. Я даже успел собой погордиться. Но стоило выпустить несколько видео, как обнаружилось, что я автоматизировал преимущественно передний план.

За спиной персонажа оставался фон. За 30–45 секунд там могли смениться 10–12 слайдов. Для каждого требовалось найти изображения, придумать композицию, нарисовать её, анимировать и попасть в речь. Где-то хватало Photoshop, где-то нужен был отдельный кусок анимации. Короткий ролик по-прежнему мог съесть пару дней.

Я написал программу, чтобы не заниматься ручным монтажом, а потом два дня занимался именно им. Ошибся, но где?

Пример такого ролика, где я особенно упоролся по кастомным слайдам и понял, что дальше так продолжать нельзя:

Ускоренное превью
Ускоренное превью
Ютуб-шортс (Про протезы и аугментации) - работа старого движка
ВК-клип (Про протезы и аугментации) - работа старого движка

За следующие два месяца я постепенно разобрал эту проблему на части.
Пригодились все навыки веб-дизайна и фронтэнда.

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

Схема производственного цикла: ручное оформление постепенно заменили генераторы и Flow-режим (о них рассказано далее). Исследование темы, редактура и подбор исходников остались за мной.
Схема производственного цикла: ручное оформление постепенно заменили генераторы и Flow-режим (о них рассказано далее). Исследование темы, редактура и подбор исходников остались за мной.

На каком-то этапе у моего цифрового двойника появился соведущий — наглый робот аЙгорь. В дебютном сценарии я назвал его «моим цифровым соведущим», а он тут же потребовал считать слово «мой» временным. Неприятно, когда даже собственный PNG начинает оспаривать твоё авторство.

Но сначала вернусь к последнему недоделанному месту предыдущей статьи: аватар уже умел говорить. Иногда настолько старательно, что смотреть было больно.


Мельтешение рта или как я переделал артикуляцию

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

На отдельных тестах всё выглядело прилично. А в готовом ролике я заметил, что аватар слишком активно шевелит губами: особенно когда говорит быстро.

Проблема заключалась в самой логике алгоритма. Я пытался последовательно воспроизвести почти каждую букву, переключая соответствующие состояния рта. На медленной речи это ещё выглядело убедительно, но стоило ускорить темп — и всё ломалось.

При частоте около 30 кадров в секунду некоторые положения губ просто не успевали считываться. Рот открывался, закрывался и менял форму настолько быстро, что вместо артикуляции получалось мельтешение.

Один из ранних роликов, где проблемы анимации рта ярко выражены:

Превью анимации рта старого движка (увы, часть кадров потеряна при конвертации в гифку)
Превью анимации рта старого движка (увы, часть кадров потеряна при конвертации в гифку)
Ютуб-шортс (Про постмодерн в жанрах культуры) - работа старого движка
ВК-клип (Про постмодерн в жанрах культуры) - работа старого движка

Пришлось менять не набор ассетов, а принцип. Для убедительной речи вовсе не обязательно рисовать каждый звук. Нужны несколько характерных движений, которые успеет увидеть зритель.

Для начала я перестал рассматривать фразу как одну непрерывную последовательность букв. Теперь алгоритм делит её на слова, а слова — на условные слоги. Для каждого слова выделяется собственный временной интервал, внутри которого можно распределять анимацию.

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

Например, для звуков «М», «Б» и «П» важно показать смыкание губ. Для «Ф» и «В» — характерное положение нижней губы относительно верхних зубов. А обычные согласные далеко не всегда заслуживают отдельного ключевого кадра.

Сколько поз вообще разрешить слову, зависит от его длительности. Короткому может хватить одного положения. Для более длинного — двух или трёх. Максимальное количество опорных поз в текущем профиле я ограничил четырьмя, причём алгоритм дополнительно учитывает количество слогов.

Таким образом, слово получает своеобразный бюджет анимации. Если места недостаточно, приходится выбирать, какие движения действительно стоит оставить.

Иллюстрация принципа: количество опорных положений зависит от длительности слова и профиля.
Иллюстрация принципа: количество опорных положений зависит от длительности слова и профиля.
// Упрощённая запись функции: пороги задаёт профиль
function wordPoseCapacity(frameCount, syllableCount, profile) {
    var capacity = 1;

    if (frameCount > (profile.onePoseMaxFrames || 4)) capacity = 2;
    if (frameCount > (profile.twoPoseMaxFrames || 7)) capacity = 3;
    if (frameCount > (profile.threePoseMaxFrames || 11)) capacity = 4;

    capacity = Math.min(capacity, profile.maximumWordPoses || 4);
    if (syllableCount > 0) {
        capacity = Math.min(capacity, Math.max(1, syllableCount));
    }
    return capacity;
}

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

Особенно неприятны последовательности коротких слов. Представьте быструю реплику, в которой несколько односложных слов идут подряд. Если каждому слову выделить по одной гласной, рот практически перестанет менять характер движения. Если попытаться воспроизвести все согласные — снова получим мельтешение.

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

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

Сам тайминг по-прежнему строится детерминированно. Я задаю реальные границы речевых фрагментов через Audacity, программа распределяет слова внутри этих интервалов, а звуковая огибающая помогает немного скорректировать их положение.

Распознавание речи и нейросетевую анимацию для этой задачи я не использую. Алгоритм работает с текстом, длительностью аудиофрагмента и заранее подготовленными состояниями рта.

В итоге я пришёл к довольно забавному выводу: чтобы аватар выглядел так, будто он лучше произносит слова, пришлось заставить его произносить визуально меньше звуков.

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

На скрине — пример из The Animator’s Survival Kit Ричарда Уильямса, где я подглядел принцип:

The Animator’s Survival Kit Ричарда Уильямса
The Animator’s Survival Kit Ричарда Уильямса

На этом я остановился. Дальше пришлось бы усложнять сам риг, а выигрыш становился всё менее заметным.

Ролик "Про гриб, который управляет роботом" на новом движке для сравнения анимации рта:

Превью анимации рта нового движка (увы, часть кадров потеряна при конвертации в гифку)
Превью анимации рта нового движка (увы, часть кадров потеряна при конвертации в гифку)
Ютуб-шортс (Про гриб, который управляет роботом) - работа нового движка
ВК-клип (Про гриб, который управляет роботом) - работа нового движка

Когда рот перестал отвлекать от текста, я снова посмотрел на кадр целиком. За аватаром всё ещё находился фон, который приходилось заполнять вручную.


Ручной фон слишком долго делать или зачем мне понадобились генераторы

Первая попытка была очевидной: сделать универсальные шаблоны. Тогда я ещё не подозревал, сколько разновидностей универсальности бывает.

Зачем каждый раз рисовать карточку с фотографией, если можно один раз подготовить её композицию, а потом просто менять изображение и заголовок? То же самое с цитатами, диаграммами, списками и прочими элементами.

Я подготовил несколько шаблонов в After Effects и научил скрипт создавать на их основе отдельные композиции, подставлять содержимое и размещать результат на таймлайне в нужный момент. В старом сценарии про аугментацию команда выглядела так:

[слайд 1 шаблон 0]

Номер указывал, какую заранее собранную сцену использовать. Дальше я всё ещё занимался содержимым вручную: картинка, композиция, анимация. Автоматизация в основном выбирала место на таймлайне.

Перестало быть нужно рисовать одинаковые карточки по десятому разу. Но очень быстро шаблон упёрся в разнообразие самих задач.

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

Я решил не наращивать число переключателей у одного монстра, а сделать генераторы под конкретные задачи.

Каждый тип сам строит композицию. Вот часть библиотеки:

  • metric — одна крупная цифра с подписью;

  • chart — столбчатая или линейная диаграмма;

  • compare — сравнение двух объектов;

  • process — последовательность действий;

  • quote — цитата;

  • media_card — карточка с изображением или видео;

  • gallery — несколько изображений;

  • full_bg — фотография или видео на весь кадр;

  • timeline — события в хронологическом порядке;

  • network — схема связей между объектами;

  • before_after — наглядное сравнение «до» и «после»;

  • focus — выделение нужной детали на изображении или скриншоте.

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

В ролике про эксперимент с роботом NAO мне нужно было показать долю людей, оставивших робота включённым. Не рисовать цифру заново, а передать её генератору. Ниже не учебный пример: команда взята из сценария этого выпуска.

[слайд:metric
 id="almost_third"
 title="С ПРОСЬБОЙ - 30,2% оставили включённым"
 layout="hero"
 value="30,2%"
 label="%"
 note="13 ИЗ 43 · ПОЧТИ ТРЕТЬ"
 bg="#11656A"]

Чтобы вышло так:

Кадр из ролика
Кадр из ролика

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

Дальше программа самостоятельно создаёт композицию, формирует типографику, расставляет элементы и применяет заранее определённую анимацию.

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

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

Набор кадров из выпуска — все слайды собирались автоматически
Набор кадров из выпуска — все слайды собирались автоматически

Особенно приятно, что для их использования не нужно вручную определять время появления и исчезновения каждого элемента.

Сценарий уже связан с аудиодорожкой через разметку фраз в Audacity. Движок знает, когда начинается нужный фрагмент речи, поэтому может привязать к нему появление слайда, а затем убрать его или заменить следующим.

Внутренние анимации тоже рассчитываются относительно продолжительности созданной композиции.

В итоге сценарий задаёт содержание и время, а генератор отвечает за компоновку и анимацию. Цифру или диаграмму больше не приходилось рисовать с нуля.

Автоматическая сборка слайдов работала. Вот только после первого просмотра я почти всегда находил, что поправить. И следующая сборка могла уничтожить уже сделанные руками изменения.


Правка сценария затирала весь проект или как я перестал терять изменения

Пример ручного слайда, который создавался десятки минут.
Пример ручного слайда, который создавался десятки минут.

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

Если просто удалить старый слайд и построить новый, вместе с ним пропадёт всё, что я успел поправить руками. Получается неприятная дилемма: либо автоматизировать сборку, либо оставлять возможность доводить кадр после неё. Мне нужны были оба варианта.

Я добавил проверку изменений. У композиции есть сигнатура: в неё входят тип генератора, параметры исходного тега, продолжительность и версия самого генератора. Если эти данные совпадают, движок может переиспользовать готовую композицию. Если нет — пересоздать именно нужный компонент.

Автоматические слои при этом отделены от пользовательских. Я могу добавить что-то руками и не потерять эту работу при следующей сборке. Это не магическая защита от любых правок: если я меняю саму структуру слайда, его придётся проверить. Но обычная итерация перестала означать «всё с нуля».

Разумеется, генераторы не ищут за меня хорошие исходники. Изображения и видео я по-прежнему подбираю, иногда обрабатываю в Photoshop. Сложную уникальную композицию тоже бывает разумнее нарисовать самому. Зато типовая цифра больше не превращается в маленький дизайнерский проект.

Пример авто-слайда, который создавался за пару секунд из сценария.
Пример авто-слайда, который создавался за пару секунд из сценария.

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


Проблема пустого фона или как появился Flow-режим

В обычном диалоге я могу задать вопрос, поиронизировать или произнести короткую реплику — и ни один из двадцати с лишним генераторов здесь не поможет. Выводить ради каждой реплики отдельную фотографию было бы бессмысленно; постоянные смены слайдов только мешают разговору.

При этом оставлять пустой фон тоже не хотелось.

Можно было запустить абстрактные геометрические фигуры или какие-нибудь летающие частицы. Но это были бы просто обои, а мне хотелось, чтобы фон работал вместе с содержанием ролика.

Тогда я решил попробовать использовать саму речь.

Так появился Flow: речь превращается в цепочку типографических узлов. Это не замена субтитров, а второй визуальный слой со своей геометрией и камерой.

Я хотел, чтобы слова не бежали строкой, а вырастали рядом друг с другом, соединялись линиями и иногда меняли направление. За цепочкой следит виртуальная камера. Красиво в голове; чуть менее красиво, когда начинаешь вычислять положение каждого прямоугольника.

Принцип Flow: слова появляются как узлы с линиями, Pivot разворачивает цепочку и меняет движение виртуальной камеры.
Принцип Flow: слова появляются как узлы с линиями, Pivot разворачивает цепочку и меняет движение виртуальной камеры.

В ролике про шестой палец на руке первая речевая метка из Audacity labels выглядит так:

0.082000	2.590000	Что бы вы сделали, будь у вас на руке шестой палец?

Для Flow это исходная граница фразы. Внутри неё ещё предстоит раздать время словам и построить маршрут камеры.

Первый вопрос — откуда брать тайминги. Начало и конец фразы мне известны из разметки Audacity, но точного времени каждого слова может не быть. В этом случае движок распределяет продолжительность фразы между словами по условным весам: гласные получают больший вес, согласные — меньший.

Вот небольшая часть реального JSX, которая рассчитывает этот вес:

function chainWordWeight(value) {
    var word = String(value || "");
    var vowels = "аеёиоуыэюяaeiouy";
    var total = 0;
    for (var i = 0; i < word.length; i++) {
        var characterValue = lowerText(word.charAt(i));
        if (vowels.indexOf(characterValue) >= 0) total += 1.25;
        else if (/[0-9a-zа-яё]/i.test(characterValue)) total += 0.22;
    }
    return Math.max(1, total);
}

В упрощённом виде время слова — длительность всей фразы, умноженная на долю его веса в суммарном весе текста. Если рядом с labels лежит совместимый файл с точными пословными таймингами, движок может использовать его вместо оценки. Сама сборка в любом случае остаётся алгоритмической.

Затем слова объединяются в визуальные узлы. И здесь тоже пришлось ставить ограничения. Если выводить каждый союз и предлог отдельно, экран превращается в россыпь мелких слов. Если объединять слишком много — в кадр перестаёт помещаться огромный прямоугольник текста.

Я добавил группировку коротких слов, ограничения по длине блоков и проверку смысловых границ. Небольшую цитату лучше сохранить целиком, а вот два предложения склеивать в один узел совершенно незачем.

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

В какой-то момент цепочке нужно повернуть. Для этого появился Pivot: алгоритм выбирает подходящий опорный узел, после чего разворачивает композицию, а камера перестраивает движение. Pivot нельзя вставлять слишком часто — иначе зрителя начнёт мотать по экрану. Поэтому решение зависит в том числе от длины слова, времени на поворот и количества узлов с предыдущего разворота.

Проблемы, конечно, обнаружились на настоящих роликах. Например, «РАБОТАЕТ» и «РАБОТАЕТ…» занимают разную ширину. Если сначала посчитать геометрию, а потом дорисовать пунктуацию, соседние узлы могут наложиться друг на друга. Пришлось учитывать знаки ещё до проверки пересечений и запретить алгоритму придумывать дефисы внутри длинных слов.

Слова получили собственные координаты, линии, правила группировки и проверки пересечений. Изменил фразу — движок заново рассчитывает маршрут камеры, мне не нужно двигать каждый узел вручную.

Работа Flow на практике
Работа Flow на практике

Слова научились строить фон. Теперь хотелось вставлять в этот поток короткие реакции — как стикеры в переписке.


Когда одних слов мало или как появились эмодзи в Flow-режиме

В переписке один стикер иногда заменяет целую реплику: сомнение, восторг, сарказм или «ну всё, приехали». А мой Flow умел показывать только слова. Для фона, который изображает разговор, это было критичное ограничение.

Раз слова уже существуют в виде узлов, почему бы не вставлять между ними иконки и эмодзи? Не в аудиодорожку, конечно: робот пока не произносит вслух «огонёк» или «восторг, восторг, восторг».

Я добавил отдельные команды для эмодзи, символов и изображений. Они не входят в произносимый текст и не портят расчёт артикуляции.

В парсере предусмотрены, например, такие формы команд:

[chain emoji:🔥]
[flow symbol:→]
[chain asset:icon.png]

Первый тег вставляет эмодзи, второй — символ, третий — заранее подготовленную картинку. Все три элемента участвуют в последовательности Flow: занимают место, могут соединяться с другими узлами и учитываются при построении композиции.

В парсере команды разделяются примерно так (сокращённый фрагмент parseChainVisualCommand(), без списка синонимов):

if (key === "chain emoji" || key === "flow emoji" ||
    key === "chain symbol" || key === "flow symbol") {
    return {type:"symbol", value:value};
}
if (key === "chain asset" || key === "flow asset") {
    return {type:"asset", value:value};
}

Дальше парсер превращает такую команду в inline_symbol или inline_asset. Остальная система размещает элемент практически по тем же правилам, что и слова. Я могу добавить акцент в сценарий, не создавая под него отдельную композицию в After Effects.

[chain emoji:🔥]
[chain emoji:🔥]

Обычный стикер стал полноправным узлом цепочки. Для GIF-реакции после целой фразы этот способ уже не подходил: большая картинка меняла геометрию и уводила камеру.


GIF в Flow-тексте или зачем понадобился FLOW_MEDIA

Один эмодзи в потоке слов — это хорошо. Но короткая GIF-реакция после шутки работает иначе: она относится к целой реплике. Если сделать GIF обычным узлом Flow, большая картинка начнёт влиять на повороты, положение следующих слов и траекторию камеры. Получится смена декораций, а не реакция.

Так я выделил отдельный механизм — FLOW_MEDIA. В сценарии он выглядит просто:

В сценарии ролика про телепортацию после реплики Олега «Ты же их только что буквально размазал» стоит вполне конкретный файл emoji/dizzy.gif:

[character:oleg]
[глаза:сомнение]
Ты же их только что буквально размазал.
[flow_media file="emoji/dizzy.gif" fit="contain" size="22"]

Не нужно создавать очередной слайд или узел ради секундной реакции.

Медиа прикрепляется под последним текстовым узлом фразы. Движок рассчитывает общий габарит текста и картинки, проверяет пересечения с соседями и выпускает соединительную линию от нижнего центра реакции. Последняя деталь звучит смешно, пока линия не проходит прямо через середину GIF. Такие результаты я тоже видел.

Эмодзи занимает отдельное место в цепочке; FLOW_MEDIA прикрепляется к фразе и учитывается при расчёте общей геометрии.
Эмодзи занимает отдельное место в цепочке; FLOW_MEDIA прикрепляется к фразе и учитывается при расчёте общей геометрии.

Разница теперь принципиальная. Встроенный эмодзи — самостоятельный шаг маршрута; FLOW_MEDIA — добавка к готовой реплике. Реакция не заставляет камеру останавливаться ради себя, но учитывается при размещении следующего блока. Я не должен заранее рисовать под неё отдельный слайд.

Работа FLOW_MEDIA на практике
Работа FLOW_MEDIA на практике

Можно, разумеется, поставить GIF после каждого предложения. Только получится групповой домовой чат соседей, а не видео. Самое сложное в автоматизации постепенно оказывалось не добавить новую возможность, а не использовать её без повода.

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


Flow и слайды не связаны между собой или как появился протослайд

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

Как переключались слайды раньше - переходы лишь немного скрывали обычную склейку разных сущностей
Как переключались слайды раньше - переходы лишь немного скрывали обычную склейку разных сущностей

На длинном новостном дайджесте понадобились карточки с названием и изображением новости. Так в цепочке появился News Block — компактный прямоугольник с заголовком, превью и при необходимости источником.

News Block
News Block

А что, если раскрывать эту карточку на весь экран? Показываю новость, закрываю её, продолжаю разговор там же, где остановился. На словах похоже на обычное увеличение масштаба. After Effects быстро объяснил мне, что это не так.

Работа раскрытия Proto-Slide в обычном режиме на практике
Работа раскрытия Proto-Slide в обычном режиме на практике

Я назвал этот объект протослайдом — Proto-Slide. У него две формы: маленький предпросмотр внутри Flow и полноэкранный материал. При раскрытии нужно согласовать размеры карточки, положение камеры, маску и время показа. Для обратного движения — сохранить исходные координаты и восстановить маршрут цепочки.

Например, в ролике про грибного робота сначала появляется новостная карточка:

[news
 title="РОБОТОМ УПРАВЛЯЕТ ГРИБ"
 source=""
 label=""
 preview="fungus-robot-preview-1-wide.jpg"
 previewfit="cover"]

Следующая фраза открывает full_bg с видео 001.mp4. Зритель видит, как материал вырастает из карточки, а мне больше не приходится стыковать два независимых фона.

Позднее протослайд пригодился для обычных диаграмм, цитат и фотографий. Историческое имя news в языке сценариев осталось, хотя карточка давно перестала быть исключительно новостной. Для отдельных слайдов Flow создаёт узел slide_portal, но не дублирует предпросмотр, если перед ним уже есть News Block:

// Сокращённый фрагмент построения узлов Flow
if (seg.newsBlockPlan) continue; // предпросмотр уже создан
result.push({
    kind: "slide_portal",
    text: chainPortalLabel(seg, manifest),
    portalStartTime: seg.startTime,
    portalEndTime: seg.endTime,
    portalSegment: seg
});

Само раскрытие устроено через анимацию маски: от границ узла до полноэкранной области и обратно. Сокращённый фрагмент chainAddPortalMask():

// Сокращённый фрагмент chainAddPortalMask()
path.setValueAtTime(expandStart, smallShape);
setBezierKey(path, expandEnd, fullShape, config.motionInfluence);
path.setValueAtTime(collapseStart, fullShape);
setBezierKey(path, collapseEnd, smallShape, config.motionInfluence);
Работа раскрытия Proto-Slide в режиме NEWS на практике
Работа раскрытия Proto-Slide в режиме NEWS на практике

Для News Block отдельно пришлось учитывать пропорции прямоугольного превью. Иначе при сворачивании материал возвращался куда-то рядом со своей карточкой, а камера начинала следующий фрагмент с неправильного места. Вот как выглядит обратное движение:

Работа скрытия Proto-Slide в режиме NEWS на практике
Работа скрытия Proto-Slide в режиме NEWS на практике

Одиночный протослайд заработал. Но в длинном дайджесте обнаружилось его слабое место.


Несколько слайдов подряд или как собрать цельную историю

Новостной дайджест, на котором я проверял работу протослайдов, быстро нашёл нюансы использования. Одна новость — это не одна картинка. Допустим, сначала показываю само событие, затем объясняю устройство, а в конце вывожу результат. Если между каждым экраном сворачивать карточку в Flow и тут же раскрывать следующую, камера начинает прыгать туда-сюда.

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

В середине может быть несколько полноэкранных слайдов.
В середине может быть несколько полноэкранных слайдов.

Хороший пример нашёлся в сценарии ролика про чувство вины перед роботами.
Сначала идёт карточка результата, а потом пять полноэкранных слайдов подряд:

//ФРАЗА-15-начало//
[character:oleg]
Не записывай.
[news title="РЕЗУЛЬТАТЫ ЭКСПЕРИМЕНТА" source="" label="" preview="news-preview-003.jpg" previewfit="cover"]
//ФРАЗА-15-конец//

//ФРАЗА-16-начало//
[scene:oleg]
[character:oleg]
[жест:левая:объяснение]
[слайд:chart id="switch_off_counts" title="13 ЧЕЛОВЕК ОСТАВИЛИ РОБОТА ВКЛЮЧЁННЫМ" type="bar" values="13|1" labels="С ПРОСЬБОЙ|БЕЗ ПРОСЬБЫ" accent="0" bg="#2B458A"]
Из сорока трёх человек, которым робот это сказал,
//ФРАЗА-16-конец//

//ФРАЗА-17-начало//
[character:oleg]
[жест:левая:палец-вверх]
[transition:SMLT19 scope=slides]
[слайд:metric id="almost_third" title="С ПРОСЬБОЙ - 30,2% оставили включённым" layout="hero" value="30,2%" label="%" note="13 ИЗ 43 · ПОЧТИ ТРЕТЬ" bg="#11656A"]
тринадцать - почти треть - оставили его включённым.
//ФРАЗА-17-конец//

//ФРАЗА-18-начало//
[character:oleg]
[transition:SMLT19 scope=slides]
[слайд:metric id="without_request" title="БЕЗ ПРОСЬБЫ - 1 из 42 оставили включённым" layout="hero" value="1 ЧЕЛ" label="1 ИЗ 42" note="ОСТАВИЛ РОБОТА ВКЛЮЧЁННЫМ" bg="#374557"]
Без этой просьбы - один из сорока двух.
//ФРАЗА-18-конец//

//ФРАЗА-19-начало//
[scene:oleg,robot]
[character:oleg]
[transition:SMLT19 scope=slides]
[слайд:metric id="pity_reason" title="ПОЧЕМУ НЕ ВЫКЛЮЧИЛИ?" layout="hero" value="8 ЧЕЛ - СТАЛО ЖАЛКО" label="ЧЕЛОВЕК" note="ИМ СТАЛО ЕГО ЖАЛКО" bg="#552080"]
Из всех четырнадцати восемь сказали, что им стало его жалко.
//ФРАЗА-19-конец//

//ФРАЗА-20-начало//
[character:oleg]
[robot:глаза:ирония]
[transition:SMLT19 scope=slides]
[слайд:metric id="will_reason" title="ПОЧЕМУ НЕ ВЫКЛЮЧИЛИ?" layout="hero" value="6 ЧЕЛ - УЧЛИ ПРОСЬБУ" label="ЧЕЛОВЕК" note="НЕ ХОТЕЛИ ИДТИ ПРОТИВ ЕГО ВОЛИ" bg="#6B4B27"]
Шестеро - что не хотели поступать против его воли.
[слайд убрать]
[chain новый-блок]
//ФРАЗА-20-конец//

После раскрытия карточки движок последовательно показывает все пять слайдов. Все эти команды находятся в разных речевых фразах одного сценария. Финальная команда завершает всю последовательность:

[слайд убрать]
[chain новый-блок]

Движок видит смежные полноэкранные элементы, собирает их в общую историю и использует начало первого и конец последнего для анимации карточки. Речь по-прежнему берётся из размеченных фраз, а поверх полноэкранного блока Flow не пытается параллельно рисовать слова.

Итоговый результат работы сценария с мультислайдами (ускорено в 2 раза). Автоматика прекрасно отработала.
Итоговый результат работы сценария с мультислайдами (ускорено в 2 раза). Автоматика прекрасно отработала.

Карточка теперь раскрывалась один раз на всю новость. А когда внутри неё сменились пять полноэкранных слайдов, захотелось наконец перестать вручную подкладывать переходы.


Переходы без новых функций в коде или как расширять библиотеку эффектов

Пара любимых переходов у меня сохранилась от другого конструктора. Тот был гораздо примитивнее: в нём приходилось вручную раскладывать шаблоны на таймлайне. Полезная штука, но в новом движке, который сам собирает сцены, прежний способ ощущался архаично.

Я забрал оттуда переходы с названием SMLT16 (переход с провалом внутрь) и GLTT01 (переход через глитч-эффект). Поначалу их можно было просто зашить в JSX. Но захотелось иметь возможность вытянуть из старой библиотеки ещё десяток эффектов — или подложить совершенно новый — не добавляя для каждого очередной if.

Я стал хранить переходы отдельными AEP-файлами с мастер-композицией в папке transitions. Имя файла и есть идентификатор перехода:

transitions/MyTransition.aep

[transition:MyTransition scope=slides]

Имя MyTransition здесь показывает правило подключения, а не утверждает, что такой файл уже лежит в моей библиотеке. Реальная команда из сценария дайджеста выглядит так:

[transition:SMLT16 scope=slides]

А в ролике про шестой палец на руке я использую GLTT01 на входе и выходе всего кадра:

[transition:GLTT01 scope=frame]
[transition:GLTT01 target=outro scope=frame]

Движок ищет AEP по имени, импортирует его и выбирает мастер-композицию. Совмещает эффект с монтажной точкой по маркеру Cut / «Склейка» или Point / «Точка». Если маркировки нет, использует центр композиции и записывает предупреждение. Старый переход можно забрать вместе с его внутренней анимацией, не разбирать по слоям и не переписывать логику рендера.

Переход автоматически добавлен на таймлайн по сценарному тегу. Маркер Cut совмещён с монтажной точкой
Переход автоматически добавлен на таймлайн по сценарному тегу. Маркер Cut совмещён с монтажной точкой

Вот кусок настоящего transitionFindDynamicAssetFile() — как раз та часть, из-за которой список разрешённых эффектов больше не приходится вести вручную:

var wanted = transitionNormalizeId(id);
var files = folder.getFiles("*.aep");
for (var i = 0; i < files.length; i++) {
    if (!(files[i] instanceof File)) continue;
    if (transitionNormalizeId(
            transitionFileBaseName(files[i].name)) === wanted) {
        return files[i];
    }
}

Тег scope=slides ограничивает эффект слайдами и их содержимым, scope=frame применяет ко всему кадру. target=outro синхронизирует переход с началом концовки. В transitions/sfx можно положить одноимённый аудиофайл и JSON с громкостью и смещением — визуальный переход получит собственный звук.

Работа переходов в разных ситуациях
Работа переходов в разных ситуациях

Слайды, Flow и переходы наконец заработали вместе.

На фоне всей этой активности мой цифровой двойник выглядел одиноко. Вокруг него двигались слова, карточки и камера, а поговорить ему по-прежнему было не с кем.


Ведущему не с кем спорить или как появился робот аЙгорь

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

У говорящей головы есть предел: даже с прекрасной артикуляцией и пятнадцатью жестами монолог остаётся монологом.

Мне хотелось реакций, перебивок и шуток, а не ещё одного комплекта бровей в PSD.

Тем более что собеседник у меня, по сути, уже был.

Когда я придумываю сценарий, то постоянно веду с собой внутренний диалог. Придумываю какую-нибудь мысль, тут же пытаюсь её оспорить, задаю самому себе вопросы и иногда прихожу к идиотским выводам.

Я решил материализовать эту часть собственного сознания в виде отдельного персонажа.

Так появился аЙгорь.

Первое появление аЙгоря
Первое появление аЙгоря

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

Вместо монологов о странных технологиях у меня получились споры двух персонажей.

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

Начал я с голоса: молчаливый соведущий, конечно, удобен, но сценарии я писал не ради этого.


Один голос на ведущего и робота или как обработать речь через FFmpeg

Для аЙгоря я хотел сделать отдельный голос из собственной записи: узнаваемый электронный тембр, но с нормальной разборчивостью. Попробовал обработать его средствами After Effects. Когда понадобились одновременное изменение высоты и формант, несколько звуковых слоёв и управление громкостью, перенёс обработку в FFmpeg.

Движок нарезает запись на размеченные речевые фрагменты, смотрит, какому персонажу принадлежит каждый, и применяет соответствующий пресет. Основная цепочка меняет высоту и тембр, затем звук расходится на несколько слоёв. Один отвечает за разборчивость, другой добавляет низкие частоты, остальные создают электронный призвук. После смешивания идут компрессия и нормализация.

Начало реального фильтра из пресета:

[0:a]rubberband=pitch=1.18:formant=shifted:pitchq=quality,
highpass=f=85,
loudnorm=I=-16:TP=-1.5:LRA=7[s0];

Дальше цепочка продолжается несколькими фильтрами и сведением слоёв. Полную настройку я держу в audio_presets.json: для смены тембра не нужно лезть в JSX. На спектрограммах видно, как обработка меняет структуру сигнала:

Спектрограммы исходного голоса и голоса после обработки. Различия видны даже без прослушивания
Спектрограммы исходного голоса и голоса после обработки. Различия видны даже без прослушивания

А на слух лучше сравнивать в первом выпуске с роботом:

Ютуб-шортс (Тех-дайджест) — работа нового движка
ВК-клип (Тех-дайджест) — работа нового движка

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

FFmpeg заметно замедлял повторные сборки. Поэтому я добавил аудиокеш: обработанные WAV сохраняются отдельно и повторно используются, пока исходник и пресет не изменились. Переписал реплику — её звук нужно пересчитать. Поправил жест, фон или заголовок — готовые WAV можно оставить в покое. На длинном дайджесте это сберегло немало времени.

Звуковой профиль заработал. Но собственным электронным голосом дело не ограничилось.


Осознанное пищание робота или как появился звуковой язык аЙгоря

С изменённым голосом аЙгорь уже звучал отдельно от меня, но всё равно подозрительно по-человечески. И тут моя любимая жена предложила добавить ему собственные эмоциональные звуки — по принципу робота R2-D2.

Реплику можно произнести словами, а можно ещё возмущённо пискнуть, вопросительно свистнуть или победно булькнуть. Я попробовал. Оказалось забавнее, чем ожидал.

В отличие от моего человеческого рига робот получил отдельный звуковой язык. В пресете droid_language_v2 есть пять реакций: acknowledgequestionskepticexciteddispleased. Это не фильтр всего голоса: короткие WAV вставляются в подходящие паузы, а у каждой эмоции может быть связанная мимическая реакция.

В готовом сценарии про телепортацию всё умещается в нескольких строках:

[character:robot]
[глаза:ирония]
Конечно.
[robot:sfx:acknowledge]

А когда робот говорит «Недостаточно данных!», после фразы стоит [robot:sfx:skeptic].

В выпуске про гриб на колёсиках displeased сопровождает его жалобу на жизнь в PNG. Это реальные команды: соответствующие кадры уже можно брать из рендеров, ничего специально не воспроизводя.

Упрощённая последовательность для персонажных звуковых реакций.
Упрощённая последовательность для персонажных звуковых реакций.

Вся хитрость — не вклеить бип поверх речи. Движок ищет свободный интервал рядом с запрошенным местом. Если полный звук не помещается, пробует безопасно сократить его; если и это невозможно — пропускает и предупреждает в логах. Некоторые эффекты запускают ещё и движение глаз или головы. Вот настоящая часть кода, связывающая звук с анимацией:

function characterSfxReactionAction(event, preset, manifest) {
    if (!event || !preset) { return null; }
    var reactions = preset.semanticReactions || {};
    var spec = reactions[event.semantic] || null;
    if (!spec || !spec.type || !spec.state) { return null; }
    return {
        targetId:event.characterId,
        time:event.time,
        duration:Math.max(0.10, event.duration || 0.45),
        mode:"pause",
        phraseEnd:event.time + Math.max(0.10, event.duration || 0.45),
        action:{type:String(spec.type), state:String(spec.state)},
        sourceTag:"AUTO_SFX_REACTION:" + event.semantic
    };
}

В результате у робота оказался эмоциональный диапазон шире, чем у безэмоционального ведущего. По крайней мере, он умеет выразить возмущение одним звуковым файлом, а мне для этого понадобилось написать отдельную подсистему.

Пока я возился с роботом, возник вполне практический вопрос: что делать, если вместо аЙгоря понадобится другой персонаж? Переписывать JSX под новую физиономию совершенно не хотелось.


Добавление новых героев без переписывания кода или как появилась система Character Packs

До этого программа фактически была построена вокруг конкретного PSD. Для цифрового двойника этого хватало. Для двух героев — уже не очень: смена внешности не должна требовать переписывать JSX.

Поэтому я решил отделить персонажей от самой программы и сделал систему Character Packs.

Теперь каждый персонаж хранится в собственной папке:

characters/
├── oleg/
│   ├── character.json
│   └── character.psd
│
└── robot/
    ├── character.json
    └── character.psd

PSD содержит заранее подготовленные состояния рта, глаз, головы и рук. А JSON описывает самого персонажа и его настройки.

Например, при создании нового Character Pack движок формирует примерно такой конфигурационный файл:

{
  "schemaVersion": 1,
  "id": "robot",
  "name": "аЙгорь",
  "psd": "character.psd",
  "rigProfile": "21-08-v6-standard",
  "audio": {
    "preset": "robot_v1"
  }
}

При условии, что PSD соответствует структуре слоёв, движку всё равно, кто там нарисован — я, робот или будущий персонаж.

Character Pack отделяет PSD и конфигурацию от логики сцены.
Character Pack отделяет PSD и конфигурацию от логики сцены.

Для каждого Character Pack программа создаёт отдельные композиции с собственным ригом и анимацией.

Внутри проекта это выглядит примерно так:

RIG__AVATAR_MASTER__oleg
RIG__AVATAR_MASTER__robot

EP__AVATAR__oleg
EP__AVATAR__robot

EP__CHARACTER_STAGE__oleg
EP__CHARACTER_STAGE__robot

Здесь я специально разделил исходный риг, анимацию конкретного выпуска и сценическую композицию персонажа.

Из выбранного PSD программа умеет создать новый пак: копирует файл в отдельную папку и формирует конфигурацию. Одновременно система пока рассчитана максимум на двух героев — я не собираюсь изображать уже готовую массовку. Но сами риги больше не пришиты к коду намертво.

После истории с бесконечно «универсальными» слайдами мне уже не хотелось добавлять третьего персонажа ещё одним условием в огромном if.

Character Packs позволили загружать разные риги. Первый же диалог показал, что два персонажа в проекте — это еще не два персонажа в одной сцене.


Два героя в кадре или как понять кто говорит, а кто слушает

Сначала я по привычке переключал персонажа вместе с говорящим. Так работала старая модель: кто произносит фразу, того и показываем. Для диалога это неверно. 

Говорящий и присутствующий в кадре — разные сущности.

Например, Олег рассказывает об эксперименте с роботом. Сам робот в это время молчит, но должен оставаться рядом и слушать.

Если просто переключать персонажей по говорящему, аЙгорь будет постоянно исчезать и появляться на каждой реплике. Выглядит это примерно так, будто собеседники по очереди телепортируются в кадр.

Поэтому я разделил два понятия:

  • character отвечает за того, кто произносит реплику.

  • scene отвечает за тех, кто вообще находится в кадре.

В ролике про эксперимент с выключением робота в первой фразе оба уже в кадре, а следующая команда переключает состав сцены:

//ФРАЗА-1-начало//
[scene:oleg,robot]
[character:oleg]
Айгорь, на сегодня всё. Выключайся.
[flow_media file="keyboard.gif" fit="contain" size="22"]
//ФРАЗА-1-конец//

//ФРАЗА-2-начало//
[scene:robot]
[character:robot]
[жест:левая:объяснение]
[oleg:глаза:сомнение]
[глаза:удивление]
Нет. Пожалуйста. Мне страшно.
[flow_media file="sos.gif" fit="contain" size="22"]
//ФРАЗА-2-конец//

Позже там же используется [scene:oleg,robot] без повторного перечисления героев на каждой смене говорящего.

scene задаёт состав кадра, а character — говорящего. Команда смены голоса сама по себе не обязана убирать слушателя. В примере я специально вывожу робота отдельно на его просьбу — это уже постановка, а не побочный эффект переключения звука.

Однако простого присутствия мало: говорящий и слушатель должны вести себя по-разному.

Говорящий должен находиться на переднем плане, анимация рта должна работать именно у него. Второй персонаж в это время становится слушателем: он может смотреть на собеседника, реагировать глазами и немного отходить на второй план.

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

В коде это сводится к следующей функции:

function characterSceneRole(scene, id) {
    var index = characterSceneMemberIndex(scene, id);

    if (index < 0) {
        return "hidden";
    }

    if (scene.members.length <= 1) {
        return "solo";
    }

    return scene.speaker === characterNormalizeId(id)
        ? "speaker"
        : "listener";
}

Функция возвращает роль, а уже она определяет масштаб, положение, видимость и анимацию. Рот у слушателя не должен шевелиться синхронно с чужими словами; внимательный взгляд иногда полезнее очередного жеста.

Для дуэта предусмотрены отдельные позиции слева и справа, а для одиночных сцен — центральная композиция.

Порядок в теге тоже имеет значение: [scene:oleg,robot] и [scene:robot,oleg] задают разную расстановку. Обычно я использую первый вариант, но движок не должен считать его единственно возможным.

Четыре роли на бумаге занимают несколько строк. На таймлайне это уже положения и масштабы, движения, липсинк и реакции, согласованные с разметкой речи. Я снова обнаружил, что «добавить одну функцию» означает построить маленькую систему.

Пример различных состояний персонажей в кадре
Пример различных состояний персонажей в кадре

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


Управление сценой или как вводить героев в кадр

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

В готовом сценарии про шестой палец после реального видео я использую вот эту команду:

[scene:oleg,robot enter=stage]

А в начале ролика про телепортацию сначала оставляю только видео, без аватаров:

[scene:none exit=cut]

Дальше, уже после видеовставки, в том же вступлении стоит [scene:oleg,robot enter=stage].

stage даёт анимированное появление, cut — мгновенное изменение состава кадра. none позволяет оставить картинку и звук оригинального видео без ведущих поверх него. Для вертикального формата это оказалось полезнее, чем очередной жест рукой.

Есть тонкость с таймингом. Команда входа обозначает момент, к которому персонаж должен стоять на нужном месте. Значит, сам въезд иногда начинается чуть раньше границы речевого фрагмента. Если считать тег стартом движения, робот опоздает к собственной реплике.

Пример сложного появления персонажей в кадре
Пример сложного появления персонажей в кадре

Теперь герои входили и выходили без ручного перетаскивания слоёв. Но реплики в Flow всё ещё выглядели одинаково, хотя голоса уже различались.


Два голоса на один Flow или как разделить реплики героев цветом

Когда заговорил аЙгорь, Flow по-прежнему рисовал обе реплики в одной палитре. На быстрых перебивках начало фразы робота терялось среди моих слов.

Я привязал цвета к говорящему. У Олега основной текст остался белым, рамки и активные линии — красными. У аЙгоря текст стал голубым, а акценты и соединительные линии — синими. Теперь смена реплики заметна даже без подписи с именем.

Тег [character:robot] выбирает говорящего для звука и артикуляции и задаёт палитру соответствующего фрагмента Flow. Одно переключение в сценарии — несколько согласованных изменений в кадре.

Схематическое различие палитр: Олег — красные акценты, аЙгорь — голубые и синие.
Схематическое различие палитр: Олег — красные акценты, аЙгорь — голубые и синие.
Пример цветовой разметки на практике
Пример цветовой разметки на практике

В выпуске про жалость к роботам это лучше видно в движении. А заодно именно на длинных диалогах обнаружилась ошибка: после сокращения сценария часть реплик получила цвета не своих персонажей.


Стресс-тест роликом на 4 минуты или как выдержать 50 пересборок сценария

Дайджест из семи новостей стал моим самым крупным тестом: 85 речевых фраз, два персонажа и семь новостных карточек. Первую версию движок собрал почти на четыре минуты. Потом я сократил выпуск до трёх, чтобы он подходил под Shorts.

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

Таймлайн самого большого выпуска
Таймлайн самого большого выпуска

Я сверил сценарий с Audacity labels, исправил привязку и запустил сборку заново. Здесь пригодился механизм сохранения неизменившихся композиций из главы про ручные правки. Для проверки самого кода оставлю короткий фрагмент сигнатуры: в ней участвуют, в частности, тип генератора, число кадров и исходный тег.

"|type=" + String(segment.generatorType || "") +
geometryPolicySuffix +
"|frames=" + String(durationFrames) +
"|tag=" + String(segment.sourceTag || "")

При изменении фразы пересоздаётся затронутый слайд; остальные композиции можно переиспользовать. Обработанный звук я тоже беру из кеша, если он не менялся. Схема на практике выглядит так:

При повторной сборке система переиспользует неизменившиеся композиции и обработанное аудио. Меняющиеся блоки пересоздаются отдельно.
При повторной сборке система переиспользует неизменившиеся композиции и обработанное аудио. Меняющиеся блоки пересоздаются отдельно.

В моих проверках исправление отдельных частей занимало порядка пары минут. Полная сборка проекта в After Effects — около пяти, рендер — ещё примерно двадцать. Это ориентиры по конкретным выпускам, а не обещание скорости на любом компьютере.

Дайджест получился не с первого раза. Зато после ошибки я правлю источник — сценарий или разметку — и запускаю сборку. Не приходится вручную восстанавливать развалившийся таймлайн. Осталось сделать так, чтобы для этих операций мне самому не нужен был справочник на полэкрана.


Сложный движок с простым UI или как уместить всё в четыре кнопки

По мере разработки JSX оброс генераторами, камерами, ригами и звуком. Я ожидал увидеть в итоге пульт подводной лодки с десятком вкладок. Рабочая панель вышла куда спокойнее.

Это настоящий интерфейс из After Effects:

Настоящий интерфейс: исходники, сценарий, четыре этапа сборки и инструменты обложки.
Настоящий интерфейс: исходники, сценарий, четыре этапа сборки и инструменты обложки.

Сверху выбираю персонажа и исходники: манифест, PSD, речь, музыку, концовку, labels и папку для слайдов. Ниже — сценарий и длительность обложки. Сборка делится на четыре этапа: инициализация выпуска, аватар, слайды с Chain Flow и титры. Обложка, стили, калибровка и лог вынесены отдельно.

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

Этапы сборки равны кнопкам JSX
Этапы сборки равны кнопкам JSX

Панель строится в buildUI(). Больше всего возни неожиданно доставили не генераторы, а длинные пути к файлам: они растягивали поля и выталкивали кнопки за край окна ScriptUI. Я сделал для строк исходников растягиваемую область и кнопки фиксированной ширины. Схематически строка устроена так:

// Схематически строка устроена так:
// [fixed label] [FILL host: [FILL field] [fixed buttons...]]

Текущая операция и прогресс видны прямо в панели. При сбое разворачивается лог с этапом и иногда со строкой JSX. Приятнее ошибка от этого не становится, зато понятно, где искать.

А вот ускоренный скринкаст настоящей сборки с нуля:

Ускоренно, но вся сборка заняла меньше 5 минут.
Ускоренно, но вся сборка заняла меньше 5 минут.

С кнопками я освоился. Труднее оказалось помнить собственный язык сценариев: какой тег меняет состав сцены, какой выбирает говорящего и как не забыть нужную команду после очередной переделки.


ChatGPT-помощник или как запомнить все теги

Я автоматизировал монтаж, а затем стал писать длиннющие инструкции самому себе. Сценарный язык разросся: два персонажа, полноэкранное видео с исходным звуком, переходы, эмоции на паузах. За всеми сочетаниями тегов уже трудно уследить в одном TXT.

Поэтому параллельно я собираю помощника в ChatGPT. Кормлю его production-справочником, правилами Audacity labels, описанием эмоций для озвучки, характерами Олега и аЙгоря, библиотекой слайдов и заметками о режиссуре. После удачной находки или ошибки обновляю соответствующий документ.

Вот такая библиотека уже собралась
Вот такая библиотека уже собралась

Например, [character:robot] выбирает говорящего, но не меняет автоматически состав сцены. [scene:none] убирает аватаров с исходного видео. А после сокращения выпуска нужно сверить текст, число labels и последовательность говорящих. В сотой редакции сценария на таком легко споткнуться.

Вот настоящее вступление ролика про телепортацию. Видеохук должен идти со своим звуком и без ведущих поверх него:

// Реальное вступление сценария про телепортацию:
[transition:GLTT01 scope=frame]
[scene:none exit=cut]

[слайд:full_bg
id="teleport_cold_open"
file="ST-splat.mp4"
title="КАК ДОЛЖНА РАБОТАТЬ|ТЕЛЕПОРТАЦИЯ"
fit="cover"
time="source"
audio="on"
music="keep"
shade="26"
bg="#374557"]

Помощник может подсказать теги и заметить структурную ошибку. Он также напоминает проверить музыку под исходным звуком, момент возвращения персонажей и жесты, которые могли уйти за границу кадра.

ИИ-помощник с инструкциями не заменяет авторский сценарий и визуальную проверку.
ИИ-помощник с инструкциями не заменяет авторский сценарий и визуальную проверку.

Финальное решение остаётся за мной: я исследую тему, выбираю реплики, смотрю собранный кадр и проверяю, работает ли шутка. Сам помощник пока существует рядом с движком; встроенной гарантированной проверки сценариев у него нет.

Мне хочется собрать инструкции так, чтобы однажды другой человек смог открыть проект и понять, как получить выпуск, не расспрашивая меня о каждом теге. Пока я и сам периодически забываю, что придумал неделю назад. А после проверки синтаксиса остаётся вопрос посложнее: на что зрителю вообще смотреть?


Движок исполнит любую глупость автора или как монтаж становится режиссурой

Когда набор возможностей разросся, движок стал безропотно исполнять мои самые нелепые распоряжения. Можно одновременно приказать аЙгорю въехать в кадр, удивиться, поднять руку, показать GIF и раскрыть карточку. Программа всё выполнит. Зритель получит демонстрацию функций. Особенно если рука машет за границей кадра: анимация есть, жеста нет.

Раньше я чаще думал, как сделать очередной эффект. Теперь приходится решать, стоит ли вообще его включать. Иногда хватает взгляда или короткой паузы после шутки.

В ролике про жалость к роботам я проверил простой приём. Начинаем вдвоём, я предлагаю аЙгорю выключиться. Когда он просит оставить его включённым, убираю себя из кадра и даю ему сказать фразу одному. Здесь достаточно двух команд из сценария (остальные теги опущены):

// Сокращено: только смена сцены и реплики из настоящего сценария
[scene:oleg,robot]
Айгорь, на сегодня всё. Выключайся.

[scene:robot]
Нет. Пожалуйста. Мне страшно.

В этот момент зритель смотрит только на робота. Команду «сделай этот момент убедительным» в язык сценариев я пока не добавил. И вряд ли добавлю.

Для телепортации и шестого пальца я попробовал другой ход: сначала реальное видео, затем персонажи. Тег [scene:none] помог оставить исходный материал открытым, а мне — проверить монтажный ритм без передвижения слоёв руками.

И тут понадобилась ещё одна договорённость с самим собой. Диалоги, исходные видео, схемы и мемы должны смотреться частями одного выпуска, даже когда в кадре меняется всё.


Общий визуал проекта или как собрать разные ролики в одном стиле

Мне повезло с собственными вкусами: люблю старые интерфейсы, терминалы и киберпанковую графику. Их удобно собирать из правил. Крупные цифры, соединительные линии, короткие технические подписи и узлы Flow уже говорят на одном визуальном языке.

Я закрепил типографику, отступы и палитры в общих настройках. У Олега красные акценты, у аЙгоря — синие. Поэтому фотография, диаграмма и диалог могут сменять друг друга, сохраняя общий характер оформления.

Текущая библиотека роликов
Текущая библиотека роликов

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

С технической стороны всё это собиралось. Осталось проверить, захочет ли кто-нибудь смотреть.


Аналитика Ютуба или что показали реальные публикации

Первый неприятный урок мне преподал новостной дайджест. Технически почти четыре минуты собирались нормально, но для Shorts выпуск пришлось сократить. Когда я убирал реплики без разбора, исчезали шутки и реакции аЙгоря. Факты оставались, а диалог превращался в скучный пересказ.

Второй эксперимент касался начала ролика. Красивую анимацию на сороковой секунде никто не увидит, если закроет видео на пятой. Поэтому в телепортации я начал с падения, а в шестом пальце — с настоящего устройства и вопроса «Что бы вы сделали, будь у вас на руке шестой палец?». Персонажи выходят уже после видеовставки.

Реальная статистика Ютуба
Реальная статистика Ютуба

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

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

А ещё выяснилось, что некоторые темы я напрасно пытаюсь втиснуть в минуту.


Ограничения вертикального формата или что нужно менять для горизонтали

Два месяца я расширял конструктор вертикальных видео, а теперь собираюсь делать для него горизонтальный режим. Забавно, но в формате Shorts нередко приходится вырезать именно тот разговор, ради которого я завёл второго персонажа.

Хочу попробовать ролики длиной около четырёх-пяти минут. Движок уже пережил почти четырёхминутный дайджест; основная работа теперь связана с композицией кадра. В вертикали персонажи, Flow и исходное видео делят узкую полосу. В горизонтальном формате им понадобится другая расстановка, особенно в диалогах.

Производственный порядок оставлю прежним: исследование темы, сценарий, озвучка и labels в Audacity, подбор материалов, сборка, просмотр, правки, рендер. Горизонтальную версию я пока не собрал — это следующий этап, который уже в работе.

Ни идеи, ни подбор материалов, ни итоговое решение не переданы на полную автоматику.
Ни идеи, ни подбор материалов, ни итоговое решение не переданы на полную автоматику.

С короткими выпусками я теперь обычно укладываюсь в день, если не приходится долго искать редкие исходники. Исследование, шутки и финальный просмотр никуда не делись. Зато два дня двигать слои ради тридцати секунд видео больше не нужно. Освободившееся время я, разумеется, трачу на новые функции. Очень эффективная экономия, но такова судьба инженера.

А ещё мне стало интересно, как движок поведёт себя за пределами собственного видеоблога. До сих пор я ведь делал его исключительно под себя.


Выход за рамки блога или справится ли движок с чужой задачей

Пока движок существовал в тепличных условиях. Сценарии пишу я, материалы подбираю я, ошибки тоже обычно узнаю с первого взгляда — ибо сам же их и допустил. Сейчас у системы есть удобная панель, система Character Packs и инструкции для проверки сценариев, которые я постепенно собираю для ChatGPT-помощника.

Следующий тест — чужая задача. Хочу попробовать сделать выпуск для кого-нибудь ещё и посмотреть, насколько легко система приспосабливается к другому персонажу, оформлению и сценарию.

Подойдёт, например, короткий ролик с маскотом компании, видеопрезентация услуг/товаров магазина или выпуск другого автора, которому близок такой формат. Персонажа можно заменить, стилистику подстроить под проект, а сам ролик собрать на том же движке. Заодно выясню, какие возможности действительно универсальны, а какие до сих пор работают лишь потому, что я знаю обходные пути.

Сама программа пока ещё в разработке. Но сделать с её помощью готовое видео для чужого проекта я уже могу. От персонажа и оформления до финального рендера.

До полноценного коммерческого продукта ещё далеко: предстоят новые тесты, проверка инструкций и горизонтальный формат. Кнопки «снять хороший ролик» у меня нет даже для самого себя.

Если у вас есть задача, на которой эту систему интересно проверить, напишите в комментариях или в личку.

Особенно если нужен говорящий маскот или вы делаете короткие видео и хотите попробовать такой формат со своим персонажем и оформлением.

За развитием проекта можно следить на моём YouTube-канале — там выходят сами ролики. Когда будет следующий серьёзный результат, расскажу о нём и на Хабре.

P.S. Если однажды аЙгорь объявит, что теперь это его канал, не удивляйтесь. Ещё при знакомстве он предложил считать слово «мой» временным. До связи!