1700 строк JS и ни одного видеофайла: как ИИ-агент снимает кино на Canvas и рендерит его в MP4

—

от автора

Минутный фильм 1080p60 со звуком — это один HTML-файл без картинок, видео и сэмплов. Код написал ИИ-агент, а headless Chrome отрендерил его в MP4 кадр за кадром. Без правил тот же агент выдаёт одну и ту же заставку: синий фон, неон, линейное движение. Разбираю, что это меняет: кадр как чистая функция времени draw(ctx, t), детерминизм, виртуальное время при рендере и самопроверка агента по контактному листу.

Фильм можно открыть в браузере и посмотреть, как он рисуется: https://kiselas.github.io/every-frame-is-code/ (клик включает звук, пробел ставит на паузу, стрелки перематывают). Музыка тоже синтезируется кодом, через Web Audio.

Правила, по которым агент его сделал, я собрал в открытый Agent Skill для Claude Code, Codex и ChatGPT. Весь код открыт под MIT: https://github.com/kiselas/every-frame-is-code

Из коробки агенты рисуют плохо

Современные модели прекрасно пишут код для Canvas и Three.js. Проблема не в коде, а во вкусе. Если попросить «сделай красивую анимацию» без уточнений, у Claude и GPT получается почти одно и то же:

  • тёмно-синий или почти чёрный фон, бирюзовый или янтарный акцент;

  • bloom на всём;

  • случайные частицы, чтобы заполнить пустоту;

  • заголовок капсом по центру;

  • всё движется линейно и одновременно;

  • единственный переход — кроссфейд.

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

Я закрыл обе проблемы одним набором правил. Технические правила задают архитектуру, художественные — то, что моушн-дизайнер сказал бы стажёру.

Одна функция вместо таймлайна: draw(ctx, t)

Весь фильм — это функция, которая рисует кадр целиком по времени t и ничего не помнит о предыдущих кадрах. Из этого следует всё остальное:

  • перемотка на любую секунду: кадр не зависит от предыдущих;

  • рендер без рывков: браузер ждёт каждый кадр, сколько бы тот ни рисовался, а в MP4 всё равно получаются ровные 60 fps;

  • правки в одну строку: поменяли кривую или цвет, перерендерили и получили тот же фильм с ровно одним отличием;

  • звук в синхроне: партитура читает тот же список событий, что и код отрисовки.

Контракт с рендером — три глобала: meta, draw(t) и ready. Флаг ready выставляется только после document.fonts.ready. Если этого не сделать, первые кадры отрисуются фолбэк-шрифтом, и в видео надпись «прыгнет».

5 правил детерминизма, или почему Math.random() запрещён

Чистая функция времени требует дисциплины. Главные правила:

1. Math.random() в коде отрисовки запрещён. Только генератор с сидом, например mulberry32:

Случайность генерируется один раз при инициализации, а в кадре только читается.

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

На этом построен огонь в демо: 650 частиц, цвет по возрасту частицы из рампы «белый → жёлтый → оранжевый → красный → тёмный», аддитивное смешивание (globalCompositeOperation = 'lighter') и заранее отрендеренные спрайты вместо createRadialGradient на каждую частицу. Градиент на каждую частицу в каждом кадре — самый частый тормоз в Canvas-анимациях.

3. Шлейфы без накопления. Классический шлейф — полупрозрачная заливка поверх прошлого кадра, и она ломает детерминизм. Замена: нарисовать объект в нескольких прошлых моментах с убывающей прозрачностью.

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

5. Если без симуляции никак (жидкость, ткань, столкновения), в режиме рендера её шагают с фиксированным dt = 1/FPS строго по порядку кадров. Для перемотки в живом режиме раз в N секунд кэшируются снапшоты состояния.

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

Рендер без рывков: виртуальное время, Playwright и ffmpeg

Скрипт рендера занимает 75 строк. Он открывает страницу с ?render, ждёт ready, читает meta и в цикле по кадрам вызывает __draw(t), снимает canvas и отдаёт PNG в stdin ffmpeg.

Время здесь виртуальное: кадр 1234 всегда соответствует t = 1234 / 60, даже если он рисовался полсекунды. Поэтому 20 000 шейдерных частиц с bloom в Three.js дают в MP4 такие же ровные 60 fps, как пустой экран. Параметры --from и --to позволяют отрендерить только кусок, чтобы проверить правку.

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

Музыка синтезируется Web Audio: осцилляторы, отфильтрованный шум, огибающие. Никаких сэмплов. Главный принцип: картинка и звук читают одну структуру данных — BPM, список событий, тайминги озвучки. Поэтому удар на экране и удар в музыке совпадают по построению, а не по подгонке.

Все функции синтеза принимают контекст, поэтому одна и та же партитура работает в двух режимах. В живом просмотре это обычный AudioContext. В рендере OfflineAudioContext считает весь трек быстрее реального времени, а страница отдаёт WAV в base64.

Скрипт рендера видит __renderAudio, сохраняет WAV во временную папку и отдаёт его ffmpeg вторым входом. Озвучку, если она есть, подмешивает через amix.

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

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

13 переходов как данные вместо одного кроссфейда

Переходы описываются массивом, как сцены и события. Каждая сцена рисуется в свой offscreen-буфер, а переход смешивает два буфера по прогрессу p:

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

В каталоге 9 переходов на Canvas 2D (iris, push, wipe, whip pan, zoom-through, glitch, light leak, dip, crossfade) и 4 на GLSL (burn dissolve, wave, chromatic split, luma wipe). Но главное в главе не код, а иерархия. Лучший переход мотивирован движением в кадре: объект на переднем плане закрывает камеру, камера въезжает в деталь, вспышка заливает кадр белым. Хуже всего — кроссфейд как единственный инструмент. Правило для агента: не больше двух-трёх типов переходов на ролик, и каждый тип привязан к смыслу.

Агент проверяет себя сам: контактный лист

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

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

./render/contact-sheet.sh out.mp4 sheet.png

Мультимодальная модель хорошо читает такую картинку: по ней видны ритм, пустые участки, повторы планов и скачки яркости. Дальше правки идут по таймкодам: «на 0:14 подпись перекрывает сферу» вместо «кажется, что-то не так». Для проверки есть чеклист: текст в безопасных зонах, время чтения не меньше 0,3 секунды на слово плюс секунда, нет статичных участков дольше двух секунд, склейки на сильную долю, громкость около −14 LUFS, два рендера совпадают.

Вкус в правилах: что отучает модель от дефолтов

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

  • Ничего не движется линейно. Каждое движение идёт по кривой, у крупных объектов есть замах и перелёт. Появление — outExpo, уход — inExpo, движение внутри кадра — inOutCubic.

  • Стаггер вместо одновременности. Элементы группы стартуют со сдвигом 30–80 мс.

  • Смелость в одном месте. Одно запоминающееся решение на ролик, всё остальное дисциплинированно.

  • Стиль следует из темы. История науки — гравюра и пожелтевшая бумага. Код — фосфорный терминал и ASCII. Детская тема — гуашь и тёплый свет. Космос не обязан быть неоновым: подойдёт и линогравюра, и советская научпоп-иллюстрация.

  • Один мир, а не слайды. Камера движется в непрерывном пространстве, сцены перетекают друг в друга.

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

Как упаковать правила в Agent Skill

Кит оформлен как Agent Skill — открытый формат, который понимают Claude Code, Codex и ChatGPT. Это папка с SKILL.md, в заголовке которого описание. Оно подскажет, когда агенту подключать навык.

Тело SKILL.md — короткий маршрутизатор. Сначала агент обязательно читает сжатый бриф на 20 правил, а затем только главы, нужные для задачи: для игры — про game feel, для 3D — про Three.js. Так контекст не забивается почти 70 КБ текста, из которых нужна пятая часть.

Установка в Claude Code:

git clone https://github.com/kiselas/every-frame-is-code ~/.claude/skills/motion-kit

В Codex то же самое, только в папку ~/.agents/skills/motion-kit. Для ChatGPT и Claude.ai есть zip, который собирается в CI на каждый тег релиза и загружается через настройки навыков.

Попробуйте такой запрос:

Make a 20-second film about the history of clocks in an engraving style, 1080p, with music.Show the plan as data first, then the code, then render it and review the contact sheet. Show the plan as data first, then the code, then render it and review the contact sheet.

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

  • Рендер через скриншоты canvas надёжен, но не быстр. Для минутного 1080p60 это 3600 скриншотов, так что черновики удобнее рендерить в 30 fps и кусками через --from/--to.

  • Сложная персонажная анимация — не сильная сторона подхода. Сильная — моушн-графика, типографика, эффекты, стилизованные сцены, инфографика, мини-игры.

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

А итог?

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

ссылка на оригинал статьи https://habr.com/ru/articles/1086766/