Как мы собрали пайплайн гибридных рилсов: живой эксперт плюс AI-видеовставки

Talking-head на камеру эксперт снимает сам, а перебивки между тезисами генерирует AI: видео без лица под синтезированную речь. История о том, как из разового скрипта вырос конвейер на пять стадий — с граблями про гипнотический loop, «бесплатные» голоса за 402 и Seedance, который отказывается снимать 3 секунды.

Как мы собрали пайплайн гибридных рилсов: живой эксперт плюс AI-видеовставки

Задача звучала обманчиво просто: короткие вертикальные ролики, где эксперт по недвижимости говорит на камеру, а между его тезисами — красивые 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.

Leave a Reply

Your email address will not be published. Required fields are marked *