Read in:
Русский

Рендер-бокс: как агент для demo-видео получил удалённую машину и очередь задач

🎬 Видео, о котором это эссе: русская версия · английская

TL;DR. Мы хотели, чтобы AI-агент сам делал demo-видео для open-source-проекта: записывал сцены, озвучивал, монтировал и проверял результат. Запись в локальной песочнице агента сломалась пятью разными способами, поэтому вся тяжёлая работа переехала на дешёвую удалённую VM, которой агент управляет по SSH. А когда боксом захотели пользоваться много агентов сразу, перед ним встал одноузловой Nomad как очередь с учётом ресурсов. Получившийся паттерн универсален: агент оркестрирует локально, удалённый бокс тащит нагрузку, очередь не даёт боксу захлебнуться.

Цель

Задача звучала просто. Есть open-source-проект с установкой в одну команду; агент должен сделать demo-ролик целиком: написать сценарий, записать терминальные и браузерные сцены, сгенерировать озвучку, смонтировать в Remotion и покадрово проверить собственный результат. Без человека за монтажным столом.

Монтаж заработал быстро. Драка началась на записи.

Парное эссе, Съёмка демо — это тест, доказывает, что запись демо — сама по себе тест продукта. Это эссе — про машину, на которой такая запись вообще работает.

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

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

1. sleep возвращает exit 144. Песочница убивает foreground-sleep. А любой скрипт записи экрана устроен как «запустить захват, поспать, набрать команду, поспать, остановить захват», и все такие скрипты молча ломаются. Обходы есть (пауза внутри expect, который живёт вне песочницы, или polling вида until xdpyinfo; do :; done), но до них доходишь, уже насмотревшись на код 144 и гадая, что это вообще за сигнал.

2. Видеофайл в 48 байт. Повторные прогоны оставляют за собой зомби Xvfb и xterm. Следующий Xvfb :97 конфликтует с трупом предыдущего, а ffmpeg x11grab спокойно пишет валидный MP4-контейнер с нулём кадров. 48 байт. Примерно каждая вторая запись падала так, пока мы не стали перед каждым дублем делать pkill -9 и удалять /tmp/.X97-lock.

3. kitty рисует пустоту. Хотелось красивый терминал. kitty требует OpenGL и под Xvfb выдаёт пустой экран. Откатились на xterm с DejaVu Sans Mono: скучно, зато работает.

4. Параллельные записи Chrome сталкиваются. Две браузерные сцены пишутся одновременно и делят один singleton-профиль Chrome. Одна запись выигрывает, вторая снимает диалог об ошибке блокировки профиля.

5. Electron под голым Xvfb ничего не отрисовывает. Самое странное. Obsidian (Electron) запускается под Xvfb, создаёт окно, и окно так и не рисуется. Захват пустой. Судя по всему, Electron ждёт события от window manager, которого голый Xvfb никогда не пошлёт. Запусти на дисплее dwm, и всё рисуется. Ни один лог на это не указывает; доходишь методом исключения.

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

Разворот: videobox

Тяжёлая работа уехала с ноутбука. Мы подняли выделенную VM (Hetzner cpx32: 4 vCPU, 8 ГБ, несколько центов в час; Terraform + Ansible, так что она воспроизводима и одноразова) и дали агенту SSH. Агент остаётся дома и оркестрирует. Бокс записывает и рендерит.

Это растворило все пять отказов разом, и вот это стоит заметить:

  • Обычный bash на обычной машине. sleep спит.
  • Чистая машина без накопления зомби. Захват стабильный; лотерея «48 байт» закончилась.
  • Свой диск. Remotion при каждом бандле копирует многогигабайтный public/ в /tmp; общую dev-машину это душило, для бокса это не событие.
  • Дисплеи с :90 по :99: параллельные записи получают каждый свой X-сервер и свой профиль браузера вместо драки за один.

Заодно мы перестали грабить X11 там, где можно без него. Терминальные сцены теперь пишутся детерминированно через asciinema + agg: проигрыш заскриптованной сессии в видео с чётким текстом и, неожиданно, идеальными цветными эмодзи. Браузерные сцены — Chrome через CDP. Сценам с Electron по-прежнему нужен Xvfb плюс dwm, по причине выше.

Патч dwm и почему маленький код — суперсила агента

Пятый отказ (Electron, который под голым Xvfb не рисуется) починил именно dwm, и починка — небольшая история, которую стоит рассказать, потому что она переворачивает всё вышесказанное.

dwm — это suckless dynamic window manager: намеренно крошечный тайловый WM на C. Ядро около 2300 строк; вся наша сборка вместе с IPC-патчем — меньше 6000. Достаточно мало, чтобы прочитать целиком за один присест. Когда над дисплеем стоит настоящий window manager, окно Obsidian сразу маппится и отрисовывается, и проблема пустого захвата исчезает.

Мы гоняем сборку с IPC-патчем: она открывает Unix-сокет /tmp/dwm.sock, так что управлением окнами можно рулить программно. Тонкий скрипт dwm-stage.mjs разговаривает с этим сокетом и поднимает сцену для записи на нужном дисплее. Именно это делает window manager скриптуемым для headless-записи, а не тем, во что надо кликать.

Вот что засело. Пропатчить dwm для агента оказалось тривиально, и причина — размер. Вся программа целиком помещается в контекст, поэтому агент читает её всю, понимает всю, и между намерением и поведением нет никакой фреймворковой обрядовости. Работает просто и как часы. Сравни со сплошным месивом, с которым агент воевал днями: невидимая зависимость Electron от события window manager, singleton-профиль Chrome, Remotion, копирующий гигабайты в /tmp, — каждая абстракция с режимом отказа, спрятанным там, куда не заглянешь. Несколько тысяч строк читаемого C — объект противоположной природы. Агент держит всю вещь в голове, и ровно это свойство делает флакающие абстракции дорогими, а маленькие инструменты дешёвыми.

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

Манифест, или откуда агент знает, что сцена хорошая

Перенос вычислений не решил верификацию, и пайплайн оброс структурой под неё. Видео состоит из сцен; сцена из микросцен; каждая микросцена объявляет типизированный воспроизводимый список record_steps: bash, launch, click, wait, capture. У каждого шага есть постусловие check, у микросцены целиком — критерий accept для финального кадра.

Выигрыш виден на отказе. Сборка вытаскивает по скриншоту на сцену в чек-лист, и vision-агент судит каждый кадр по его критерию accept. Когда что-то не так, падение называет конкретный шаг: «ожидал в выводе синка строку 'Pushed N notes'» указывает на один шаг одной микросцены, а не на смутно плохое финальное видео. Пересниматься будет только эта микросцена, по id. Отладка видео перестаёт быть археологией.

JSON и есть система

Всё вышеописанное крутится вокруг одного файла. Сценарий — это единый декларативный JSON-манифест: сцена, затем микросцены, и каждая микросцена несёт свои record_steps, свой audioText и свой критерий accept. Видео, нарезка аудио, план записи, тест-спека — всё это проекция одного файла. Если бы надо было ткнуть в настоящий тезис проекта, я бы ткнул в JSON, а не в бокс или очередь.

Из того, что данные вынесены в ядро, следует четыре вещи, и каждая — конкретный выигрыш, а не эстетика.

Агент читает и пишет его тривиально. Это просто данные, без фреймворка, поэтому агент проглатывает весь сценарий разом и точно знает, каким должно быть видео. То же свойство, что делало лёгким dwm: достаточно мало и читаемо, чтобы держать целиком.

Он линтуется. Структура, обязательные поля, ссылки на ассеты, типы шагов, тайминги, которые не сходятся, — всё валидируется статически до того, как сгорит хоть секунда рендера. Большинство багов «плохое видео» вообще не доходят до рендера, потому что это ошибки схемы, а их ловить дёшево.

Он делает очевидным «чего не хватает». Манифест объявляет каждую сцену и каждый нужный ей ассет. Сравни объявленное с наличным — и получишь точный список работ: этот клип не записан, тот слайс озвучки не сгенерирован. Регенерация адресна, по id, а не «переделай всё видео», и именно это делает часовой цикл рендера терпимым.

Чек-лист приёмки выпадает бесплатно. Раз каждая микросцена уже объявляет критерий accept и тайминг, код проходит по манифесту и выдаёт пер-сценный чек-лист: таймстемп, промпт и автоматически вытащенный скриншот, который дальше проверяет vision-агент. Один и тот же файл — и вход сборки, и тест-спека. Тесты не пишутся отдельно; они срез той же вещи, которую ты собираешь.

Второй язык сделал это наглядным. Сначала мы выпустили видео на русском, потом захотели английское. Переозвучка — лёгкая половина: сгенерировать пер-сценную озвучку английским голосом и перерендерить. Но в нескольких сценах русский был вшит в пиксели, а не в звук: страницы документации, по которым ходит браузер, редактируемая заметка Obsidian («Привет из Obsidian!»), домашняя страница опубликованного сайта, маленькие подписи в macOS-заголовке окна. Их пришлось переснимать уже в английском контексте: английские доки, английская заметка, английский сайт. Именно манифест превратил это в точечную работу, а не в пересъёмку. С ключом по id сцены он сказал ровно, какие сцены несут язык в визуале и требуют нового дубля, а какие языконезависимы и их можно не трогать: терминальная установка, админка. Второй язык оказался диффом над манифестом: поменять аудио везде и переснять лишь горстку сцен, чей визуал действительно говорит.

Сквозная мысль та же, что показал dwm с другой стороны. Читаемый декларативный файл данных для этого пайплайна — то же, чем маленькая C-кодовая база является для управления окнами: достаточно мал, чтобы держать целиком, поэтому и человек, и агент рассуждают о нём напрямую. Данные — контракт; код лишь исполняет. И это ложится на хребет всей конструкции. Агент оркестрирует, бокс считает, очередь планирует, но вещь, которой агент реально манипулирует, объект, в котором он мыслит, — это JSON.

Дальше десятки агентов, отсюда Nomad

Один агент и один бокс — это просто SSH. Но как только рендер стал удалённым вызовом, вызывать его начинают многие, а Remotion-рендер с удовольствием съедает все четыре ядра на минуты. Три одновременных рендера — и всё тормозит, а один из них, скорее всего, роняет бокс по памяти. Нужен admission control: пускать максимум N, остальных в очередь.

Мы взяли HashiCorp Nomad как одноузловую batch-очередь (сервер и клиент в одном процессе, драйвер raw_exec) и провалидировали вживую на одноразовом cpx32: тестовая VM прожила 24 минуты и стоила около €0.02. Механика почти неприлично простая. Каждый рендер объявляет resources { cpu = 3500 }; узел отдаёт 8000 MHz; значит, одновременно бегут два, остальные висят pending с внятной причиной: Dimension "cpu" exhausted on 1 nodes. Мы отправили шесть задач и смотрели, как они уходят ровными волнами по две, с паузой ноль-одна секунда между освобождением слота и стартом следующей. Ни поллера, ни крона: планировщик сам пересматривает заблокированные аллокации, когда освобождается ёмкость.

Одну оговорку надо сказать прямо: у raw_exec цифры ресурсов — это резервирование для планирования, а не ограничение. Cgroup не создаётся. Пока два рендера, «резервируя» по 3500 MHz, работали, оба ffmpeg сидели на ~180% CPU, а бокс целиком на 95%. Для выделенного рендер-бокса это ровно то, что нужно (рендер должен использовать всю машину, очередь лишь не даёт навалить сверху ещё), но значит memory надо задавать по реальному пику рендера, а не по желаемому потолку, иначе две задачи сядут вместе и вместе словят OOM.

Интерфейс для агента — один хелпер. render-enqueue <id> "<cmd>" по SSH диспатчит задачу, поллит аллокацию до завершения, стримит логи обратно и выходит с настоящим exit-кодом рендера. Внутри закопаны два номадовских капкана, чтобы вызывающие с ними не встречались: nomad alloc logs -f может висеть вечно после завершения задачи (три наших SSH-сессии так и застряли), а runtime-интерполяция ${...} в Nomad не переваривает башевский ${VAR:-default} во встроенных скриптах. Очередь переживает рестарт демона вместе с работающими ffmpeg: Nomad переприцепляется к своим executor-процессам, и ffmpeg, стартовавший до systemctl restart nomad, после него завершился с кодом 0. В простое это примерно 118 МБ RSS и ~1% CPU. Всё уехало в Ansible-роль плюс два маленьких скрипта.

Можно ли было проще? FIFO на flock или GNU parallel ограничат параллельность, но не дадут ни очереди, переживающей ребут, ни логов и статуса по задачам, а exit-коды придётся собирать руками. Наши же встроенные Go-очереди (goqite/backlite) означали бы писать на боксе submit-API и воркер. Nomad — это один apt-пакет и ~50 строк конфига за планирование по ресурсам, пер-задачные логи, exit-коды и живучесть при рестартах. Для многих внешних агентов по SSH это меньше кода, а не больше.

Что дальше, честно

Сегодня рендер-боксы — это CPU-шные dev-VM, арендованные примерно на час и уничтоженные. Очевидный следующий шаг — тот же паттерн на GPU-хосте: аппаратный энкодинг, быстрый Remotion, место под ML-шаги вроде forced alignment в той же очереди. Мы этого не построили. Семантика очереди при этом не изменилась бы вовсе, что говорит в пользу конструкции, но пока это намерение, а не результат.

Паттерн

Убери видеоспецифику, и останется вот что: агент — оркестратор, удалённый бокс с SSH — субстрат тяжёлых вычислений, очередь с учётом ресурсов — планировщик между ними. У тяжёлой, флакающей и параллельной работы одно общее свойство: в песочнице агента ей хуже, на выделенной машине лучше. Агенту не нужны локальные вычисления. Ему нужен надёжный способ сказать «выполни это и скажи exit-код» и умение подождать своей очереди, когда машина занята.

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