Задача звучала обманчиво просто: короткие вертикальные ролики, где эксперт по недвижимости говорит на камеру, а между его тезисами — красивые AI-перебивки: стройка, транспорт, документ крупным планом, телефон в руке. Лицо снимает человек, атмосферу генерирует нейросеть. Из разового скрипта на одну генерацию это выросло в конвейер на пять стадий, и по дороге мы собрали почти все грабли, которые в такой сборке можно собрать.
Дальше — как он устроен, почему стадий именно пять и в каком порядке они ломались, пока мы не расставили их правильно. Аудитория поста — те, кто сам стыкует внешние модели (text-to-video, TTS, LLM) в один пайплайн и хочет предсказуемую себестоимость секунды картинки, а не «магическую кнопку».
Почему гибрид, а не «всё сгенерировать»
Соблазн был очевидный: сгенерировать говорящую голову целиком и не гонять человека на съёмку. Но полностью синтетический talking-head сегодня дорогой, лицо плывёт от кадра к кадру, а под недвижимость — оффер, где за слова отвечают юридически — аватар не годится. Клиенту нужен узнаваемый эксперт, которому верят, а не цифровой двойник, которого через месяц спросят, откуда он взял «гарантированную доходность».
С другой стороны, снимать каждую перебивку тоже дорого: выезд на объект ради трёх секунд атмосферы. Так и получился гибрид. Каркас речи эксперт говорит сам — это лицо и доверие. Между его абзацами встают AI-мосты: генеративное видео без лица плюс синтезированная речь.
Одно решение с самого начала было принципиальным и осталось таким: лицо мы не генерируем. System-промпт и brief прямо запрещают лица в text-to-video. AI отвечает только за атмосферный B-roll — там, где съёмка дорогая, а точность лица не нужна, потому что лица в кадре нет.
Пять стадий и жёсткий порядок
Стек сознательно скучный: чистый Node ESM без Python, ручной парсер .env, fetch к HTTP API, локальный ffmpeg из npm-пакетов. Никакого оркестратора, очередей и UI — каждый шаг это читаемый .mjs на десятки-сотни строк, который можно прогнать руками.
Порядок такой: LLM заполняет слоты сценария → TTS озвучивает вставки → text-to-video генерирует картинку → mux кладёт речь поверх видео → финальная склейка чередует живые кадры с AI-вставками. Разберём, что на каждой стадии оказалось неочевидным.
Стадия 1. Сценарий: что генерирует модель, а что руками не трогаем
Сценарий делится на две части, которые нельзя смешивать. Есть каркас — фиксированные абзацы, которые эксперт уже произнёс на камеру. Их трогать нельзя: сдвинешь слово — и baseline-клип на диске перестанет совпадать с текстом. Между абзацами стоят плейсхолдеры <CUT 1>, <CUT 2> — слоты под AI-вставки.
Первое, обо что мы споткнулись: попросили модель «заполнить слоты», а она переписала весь сценарий. Целиком. Красиво, складно — и мимо уже отснятого видео. Оказалось, «заполни слоты» LLM охотно читает как «сделай хороший сценарий с нуля», особенно если сценарий ей чем-то не нравится.
Уговаривать модель в промпте «не трогай фиксированные абзацы» бесполезно — текст в промпте она соблюдать не обязана. Это та же история, что и с периметром безопасности агента, который не держится на одном system prompt: ограничение работает, когда оно вшито в код, а не в вежливую просьбу. Мы сменили контракт: модель возвращает только CUT-блоки, слияние с каркасом делает скрипт. Детерминированный merge даёт идемпотентный повтор, побитовое совпадение baseline-текста и чистый diff — меняются только вставки. Плюс сырой ответ модели сохраняется отдельно, чтобы пересобрать сценарий без нового API-вызова, когда правишь не контент, а логику слияния.
Каждый слот описан в brief: роль в воронке (боль / метод / доверие / усиление призыва), смысл реплики (без готовой формулировки — её пишет LLM), тип кадра и стык с соседними абзацами. Глобальные ограничения — вертикаль 9:16, устно три-пять секунд, без цен, конкретных ЖК, обещаний доходности и гарантий ипотеки. Это и compliance, и страховка от того, что модель выдумает цифру.
Стоила эта стадия смешных денег: около $0.009 за прогон на дешёвой chat-модели. На фоне видео — пыль.
Стадия 2. Речь первой, и длину берём из файла, а не из головы
Здесь ключевое правило всего пайплайна: сначала речь, потом видео. Из каждого CUT-блока берётся текст реплики, уходит в ElevenLabs (мультиязычная модель, premade-голос), на выходе cut-N.mp3.
И вот тонкость, которая потом спасла нам весь монтаж. Длительность клипа мы берём не из ответа API и не из оценки «примерно пять секунд», а измеряем локальным ffprobe по готовому mp3. Источник длины прямо пишется в манифест: "duration_source": "ffprobe". Тот же принцип, что и в дисциплине измеримого результата вместо ощущений: там про то, что зрелость агента меряют харнесом, а не «на глаз», здесь — что длину реплики меряют пробой файла, а не догадкой. В реальном прогоне четыре реплики дали 6.0 / 4.6 / 6.3 / 5.1 секунды. Уже не «все по пять», как обещал brief.
Именно эта измеренная длина дальше задаёт длительность видео. Если бы мы поверили оценке — получили бы рассинхрон на каждой второй вставке.
Стадия 3. Text-to-video и Seedance, который не умеет три секунды
Картинку генерирует Seedance 1.5 Pro через OpenRouter: вертикаль 9:16, разрешение 480p, генерация звука выключена — звук уже есть из TTS, а нативное аудио модели просто удорожает секунду впустую.
Первый сюрприз этой стадии: заказываешь трёхсекундный клип — прилетает HTTP 400. Оказалось, Seedance принимает длительность только в диапазоне 4–12 секунд, и в маркетинговых «коротких клипах» это нигде не написано. Отсюда родилась формула, которая заодно стала главным рычагом «стоимость против качества»:
requested = max(4, min(10, ceil(speech_duration_sec)))
Нижняя четвёрка — требование API. Верхняя десятка — жёсткий cost guard: API формально даёт до 12 секунд, но каждая лишняя секунда видео это деньги, и мы решили платить не больше, чем за десять. ceil — чтобы клип не оказался короче речи, когда та чуть переваливает за целое число секунд.
Стадия 4. Mux: речь поверх видео, и гипнотический loop
Для каждой пары «видео + речь» скрипт снимает ffprobe длительностей и, если видео короче звука, зацикливает картинку до конца реплики. Запас в 50 миллисекунд в условии гасит ложные loop от округления пробы. Дальше ffmpeg кладёт речь поверх, обрезает по длине реплики, кодирует в H.264 + AAC.
И вот здесь всплыл симптом из раннего прогона, который мы прозвали гипнотическим: четырёхсекундный B-roll крутится два-три раза под одну длинную фразу, и вставка выглядит как залипшая гифка. Причина была ровно в том, что видео звали с фиксированной короткой длиной, а модель писала длинную речь — brief «три-пять секунд» никто не enforce'ил.
Лечение вышло из трёх движений сразу: TTS первым и длина из ffprobe; ceil(речь) в Seedance с потолком в десять секунд; и укоротить саму речь в brief до одного-двух предложений. Loop оставили — но как страховку на редкий случай, а не как штатный режим.
Сравнение двух поколений пайплайна получилось поучительным:
| Ранний прогон | После ужесточения | |
|---|---|---|
| Речь на слот | длинные абзацы (до ~13 с) | 1–2 коротких предложения (~5–6 с) |
| Длительность видео | фиксированные 4 с | из TTS: 6 / 5 / 7 / 6 с |
| Зацикливание | все 4 клипа | ни одного |
| Стоимость видео | ~$0.18 | ~$0.28 |
Парадокс налицо: после ужесточения стало дороже. Но честнее — нет троекратного прокручивания одного клипа под длинную речь. Десять центов разницы окупаются тем, что вставки перестают гипнотизировать зрителя.
Стадия 5. Склейка телефона и нейросети
Финальный рилс собирается по timeline.json — простому списку, где чередуются baseline-клипы с телефона и номера AI-вставок. Связь речи и клипов держится на порядке, а не на таймкодах внутри одного файла: любой кусок можно переснять, не пересобирая тайминги.
Последняя грабля ждала на стыке двух миров. iPhone .MOV и Seedance .mp4 почти всегда несовместимы по кодеку, разрешению и частоте кадров — попытка склеить их «копированием потока» даёт чёрные кадры или рассинхрон звука. Так что для полного ролика мы сразу гоним re-encode с единым холстом: scale + pad до общего размера, 30 fps, yuv420p, AAC. Клипам без звуковой дорожки подставляется тишина, чтобы аудио не прыгало на стыках. Один такой ролик — шесть живых кадров плюс четыре вставки — вышел около 81 секунды и 113 МБ. Ожидаемо для вертикального HD с телефона.
Манифесты как бухгалтерская книга
Каждая платная или измеримая стадия пишет рядом с артефактами JSON: длительности речи и потраченная quota, jobId и стоимость каждого клипа, зациклено видео или нет. Звучит как бюрократия, но окупается сразу. По этим файлам можно объяснить счёт от OpenRouter и ElevenLabs по каждому клипу, пересобрать только одну вставку вместо всего ролика, отладить «почему видео зациклилось» и не гадать, откуда взялась длина Seedance.
Само медиа (mp3, mp4, исходные .MOV) лежит в .gitignore. В репозитории остаются сценарии, brief, timeline и манифесты — воспроизводимость без гигабайтов в истории.
Грабли, которые не вошли в основной сюжет
Пара историй, которые стоят отдельного абзаца, потому что съели по вечеру каждая.
«Бесплатный» голос, который стоит 402. Взяли голос из библиотеки ElevenLabs с честной пометкой вроде «доступно free-пользователям» — и получили paid_plan_required. Выяснилось: на free tier через API работают только premade-голоса, а shared/library требуют платный план, даже если каталог намекает на обратное. free_users_allowed: true в каталоге ≠ право синтезировать с бесплатным ключом. Для «родного» русского тембра нужен платный план или другой провайдер.
ffprobe-static — это не ffmpeg-static. Один npm-пакет экспортирует строку с путём, другой — объект { path }. Перепутаешь — и spawn падает с undefined вместо пути. Спасает одна строчка везде, где дёргаешь бинарник: typeof mod === "string" ? mod : mod?.path.
Windows без ffmpeg и с фальшивым Python. На тестовой машине системный python открывал магазин приложений (заглушка из WindowsApps), а ffmpeg в PATH не было вовсе. Node-only пайплайн плюс статичные бинарники, которые тянет npm install в скиллы, сняли вопрос без просьб «поставьте нам ffmpeg».
Где здесь деньги
Экономика одного цикла на четыре вставки, 480p, короткие реплики:
| Стадия | Порядок величины |
|---|---|
| Заполнение слотов (дешёвая chat-модель) | ~$0.01 |
| ElevenLabs TTS (free quota) | $0 (или центы на paid) |
| Seedance × 4 | ~$0.20–0.35 |
| Mux и склейка | $0 (локальный CPU) |
| Итого по API | ~$0.25–0.40 |
Дорогая часть здесь не API, а человек: съёмка живого каркаса, монтажная приёмка, юридическая вычитка оффера. Пайплайн удешевляет ровно итерации вставок — перегенерировать один CUT дешевле, чем переснимать весь ролик. И именно поэтому cap в десять секунд на клип и запрет нативного аудио у модели — не микрооптимизация, а продуктовое решение. Коллекция видео-моделей на OpenRouter, к слову, сортируется по популярности, а не по цене — дорогую модель легко выбрать по невнимательности.
Что из этого переносится на другие сборки
Три вывода уходят почти дословно на любой мультимодальный конвейер, не только на рилсы.
Первый: разделяйте неизменяемый каркас и генерируемые слоты — в данных, в коде и в правах модели. Дайте LLM переписывать всё — она перепишет всё.
Второй: длительность мультимодального клипа измеряется, а не угадывается. TTS → ffprobe → длина видео — единственный порядок, который не разваливается в рассинхрон.
Третий: ограничения моделей — часть дизайна продукта, а не баг интеграции. Диапазон 4–12 секунд, free против library-голосов, стоимость нативного аудио — это всё закладывается в схему заранее, а не отлавливается в рантайме по HTTP 400.