Агент снял минутный фильм одним HTML-файлом и сам его проверил

Кейс: 1700 строк JS, headless Chrome кадр за кадром, MP4 на выходе и контактный лист как обратная связь.

· 5 мин чтения

Автор опубликовал на Хабре кейс: минутный фильм 1080p60 со звуком. Это один HTML-файл без картинок, видео и сэмплов. Код написал ИИ-агент, а headless Chrome отрендерил его в MP4 кадр за кадром. Демо открывается в браузере, музыка синтезируется через Web Audio. Весь код открыт под MIT.

Кадр как чистая функция времени

Весь фильм — это функция draw(ctx, t), которая рисует кадр целиком по времени t и ничего не помнит о предыдущих. Из этого следуют перемотка на любую секунду, рендер без рывков и правки в одну строку. Контракт с рендером — три глобальные переменные: meta, draw(t) и ready. Флаг ready выставляется только после document.fonts.ready, иначе первые кадры отрисуются запасным шрифтом.

Пять правил детерминизма

Главное ограничение: Math.random в коде отрисовки запрещён. Случайность генерируется один раз через генератор с сидом (mulberry32), а в кадре только читается. Частицы без состояния: каждая получает постоянные параметры, а возраст вычисляется циклически из t. На этом построен огонь в демо: 650 частиц, цвет по возрасту из рампы «белый → жёлтый → оранжевый → красный → тёмный», аддитивное смешивание и заранее отрендеренные спрайты вместо createRadialGradient на каждую частицу. Шлейфы делаются иначе: объект рисуется в нескольких прошлых моментах с убывающей непрозрачностью. Для пружин берут аналитическое решение затухающего осциллятора, и оно уже чистая функция времени. Если без симуляции не обойтись, в режиме рендера её шагают с фиксированным dt = 1/FPS строго по порядку кадров, а для перемотки в живом режиме раз в N секунд кэшируются снапшоты состояния.

Рендер через виртуальное время

Скрипт рендера занимает 75 строк. Он открывает страницу с ?render, ждёт ready, читает meta и в цикле по кадрам вызывает __draw(t), снимает canvas и отдаёт PNG в stdin ffmpeg. Время виртуальное: кадр 1234 всегда соответствует t = 1234/60, даже если он рисовался полсекунды. Поэтому 20 000 шейдерных частиц с bloom в Three.js дают в MP4 ровные 60 fps, как пустой экран. Параметры –from и –to позволяют отрендерить только кусок для проверки правки.

Звук и грабли с отрицательным временем

Картинка и звук читают одну структуру данных: BPM, список событий, тайминги озвучки. В живом просмотре работает обычный AudioContext, в рендере OfflineAudioContext считает весь трек быстрее реального времени и отдаёт WAV в base64. Скрипт рендера сохраняет WAV во временную папку и отдаёт его ffmpeg вторым входом.

На одни грабли автор всё-таки наступил. В живом режиме звук включается по клику, и клик может прийти посреди фильма. Защита currentTime > 0 была нужна, чтобы не пропускать события в офлайн-рендере, но у только что созданного AudioContext currentTime тоже равен нулю. При клике на 20-й секунде партитура пыталась запланировать уже прошедшие ноты на отрицательное время и падала с RangeError: Time must be a finite non-negative number. Картинка при этом шла дальше, так что при локальной проверке «с начала» баг не проявлялся. Лечится одной строкой: в офлайн-режиме T(x) = x ≥ 0, и защита не нужна.

Агент проверяет себя сам

Агент, который написал код и сказал «готово», почти всегда ошибается в композиции: текст наезжает на объект, три секунды ничего не происходит, элемент уезжает за край. Сам он этого не видит, потому что не смотрит на результат. Поэтому после каждого рендера кит заставляет агента собрать контактный лист (кадр каждые полсекунды на одной картинке), посмотреть на него и описать увиденное до того, как что-то править. По листу видны ритм, пустые участки, повторы планов и скачки яркости. Дальше правки идут по таймкодам: «на 0:14 подпись перекрывает сферу» вместо «кажется, что-то не так».

Правила собраны в открытый Agent Skill. Это формат, который понимают Claude Code, Codex и ChatGPT. Кит оформлен как папка с SKILL.md, в заголовке которого описание, а в теле короткий маршрутизатор: агент сначала читает сжатый бриф на 20 правил, затем только нужные главы. Так контекст не забивается почти 70 КБ текста, из которых нужна пятая часть.

Где подход не работает

Рендер через скриншоты canvas надёжен, но не быстр: для минутного 1080p60 это 3600 скриншотов. Черновики удобнее рендерить в 30 fps и кусками через –from/–to. Сложная персонажная анимация — не сильная сторона подхода. Хорошо подходят моушн-графика, типографика, эффекты, стилизованные сцены, инфографика, мини-игры. Правила не отменяют режиссуру: кит отучает модель от дефолтов, но лучший результат получается, когда стиль задаёте вы.

Как повторить

  • Установите кит в свой агентный клиент: git clone https://github.com/kiselas/every-frame-is-code ~/.claude/skills/motion-kit для Claude Code, то же самое в ~/.agents/skills/motion-kit для Codex. Для ChatGPT и Claude.ai есть zip, который собирается в CI на каждый тег релиза и загружается через настройки навыков.
  • Откройте демо в браузере и проверьте, что кадр зависит только от t: стрелками можно перематывать на любую секунду.
  • Для черновика рендерите кусок через –from и –to, чтобы быстро проверять правки.
  • После рендера попросите агента собрать контактный лист через ./render/contact-sheet.sh, описать увиденное и сформулировать правки по таймкодам: не «что-то не так», а «на 0:14 подпись перекрывает сферу».
  • Дайте агенту задачу со стилем: «20-секундный фильм про историю часов в стиле гравюры, 1080p, с музыкой. Сначала план как данные, потом код, потом рендер и разбор контактного листа».