Я сгенерировал 48 кадров своего лица в ChatGPT. В продакшен ушло 11 — в сумме 46 килобайт.
На главной странице kishkin.dev стоит моя фотография в чертёжной рамке. До недавнего времени это была просто фотография. Теперь аватар следит за курсором, моргает — и улыбается, когда вы тянетесь к кнопке. Одно фото с телефона весит в сто раз больше, чем весь эффект.
По дороге я построил и выбросил семь направлений — трёхмерную сетку, карту глубины, оптический поток. И упёрся в предел, который не обойти: генератор выполняет «посмотри вбок», но не выполняет «насколько». Ниже — измеренная цифра под каждым из этих решений.
Коротко. Портрет на главной следит за курсором, моргает и улыбается, а стоит странице 7 398 байт в двух запросах на загрузке; остальные 38 930 приходят, только когда сдвинулась мышь. По дороге я построил и выбросил семь направлений: трёхмерную сетку, карту глубины, оптический поток, подвижный свет, плавный переход между взглядами. Главное, за что я заплатил двумя днями: генератор выполняет «посмотри вбок», но не выполняет «насколько» — на дословно одинаковом промпте амплитуда взгляда разлеталась от 94 до 130 % внутри одной пачки.
ЛИСТ · АВТОПОРТРЕТ
REF DK-01

Это не снимок. Это тот самый блок, что стоит на главной, — наведите курсор.
Сколько это стоило: 33 промпта, 48 кадров, 46 КБ
Эффект стоил 33 написанных промпта и 48 сгенерированных кадров, из которых в продакшен ушло 11. Всё сгенерировано в ChatGPT images 2.0 — это важно, потому что всё, что ниже про амплитуду взгляда, относится именно к этому движку и именно к этой версии. Вместе они весят 46 328 байт. Каждый кадр — это моё лицо, сгенерированное из одной реальной фотографии: тот же человек, тот же ракурс, изменены только глаза или только рот.
| Показатель | Значение |
|---|---|
| Написано промптов | 33 |
| Сгенерировано кадров (ChatGPT images 2.0) | 48 |
| Ушло в продакшен | 11 |
| Весит всё вместе | 46 328 байт |
Из 48 попыток выжили восемь направлений взгляда, два положения век и одна улыбка.
Остальные 37 — это и есть работа. Не черновики, а проверенные и закрытые гипотезы.
Ложный путь: полторы недели на перетекание фото в маскота
Одиннадцать прототипов подряд разбились об одну цифру, которую я измерил в самом начале. До этого плита имела два состояния: фотография и рисованный маскот, и портрет должен был «перетекать» в иллюстрацию.
Голова маскота на 7 % шире моей на фото, а плечи — на 10 % уже. Ни один трансформ не совпадает с обоими одновременно.
Дальше был список изобретений, чтобы это расхождение спрятать: переворот плитки, размытие, выносные подписи, подмена фона, стопка листов, окошко-лупа, WebGL, флип карточки.
Паттерн везде одинаковый:
Усилия уходили на сокрытие несоответствия. А сокрытие всегда читается дёшево.
Разворот был простой и бесплатный. Не делать вторую картинку. Взять одну фотографию и заставить её реагировать. Прятать ничего не нужно — потому что прятать нечего.
Разворот того же рода я уже делал с внешним диском: отказался от NTFS-драйвера в macOS вместо того, чтобы искать понадёжнее.
Семь подходов, которые я построил и выбросил: 3D-сетка, глубина, оптический поток
Каждый из семи был живым прототипом, а не идеей на салфетке. Трёхмерная сетка на 16 тысяч точек работала и оказалась избыточной. Карта глубины дала 5,29 пикселя движения на голове, у которой на экране 102. Оптический поток нашёл 0,2 пикселя там, где глаз прошёл 10. Вместо всего этого остался параллакс на чистом CSS.
Фотосессия со штативом. Технически самый правильный путь: 20 минут, штатив, серия кадров «глаза влево / вправо / вверх / вниз». Я даже расписал себе порядок кадров — он до сих пор лежит в репозитории. Отброшено, потому что я не сниму себя хорошо — и это нормальная причина.
Трёхмерная модель лица. Three.js, настоящая сетка на 16 тысяч точек. Прототип работал. Отброшено как избыточное.
Карта глубины. Самое дорогое из выброшенного: я построил её целиком, с отдельным стендом, чтобы увидеть эффект в движении. Посчитал честно: 5,29 пикселя движения «нос против уха» на голове, у которой на экране 102 пикселя. За эти пять пикселей пришлось бы платить WebGL, запасным путём и перекалибровкой каждый раз, когда сменится фотография.
Подвижный свет. Выглядело так, будто в лицо светят фонариком. Отброшено на глаз за десять секунд.
Плавный переход между взглядами. Кажется очевидным — и не работает в принципе. Между «смотрит прямо» и «смотрит вбок» нет промежуточного кадра. Если смешать два кадра пополам, тёмная радужка одного ложится на белый белок другого, и глаз становится серым, как при катаракте.
Оптический поток. Вторая попытка сделать промежуточный кадр, математически. Алгоритм нашёл 0,2 пикселя движения там, где глаз прошёл 10: тёмная радужка, едущая по гладкому белку, для него просто невидима.
Промежуточная амплитуда из генератора. Об этом — отдельно, ниже.
Вместо глубины остался чистый CSS: стена позади движется на 25 пикселей, портрет — на 5. Двадцать пикселей параллакса за ноль новых файлов.
Можно ли задать ChatGPT точный угол взгляда?
Нет. Генератор выполняет «посмотри вбок», но не выполняет «насколько». Я просил половину от максимума пятью разными формулировками: «около 18 градусов» дало взгляд до упора, «на четверть ширины радужки» — 81 %, координаты в сантиметрах и пикселях — 117 и 124 %. Формулировка, которая попала в 74 %, на повторе дала 111 % с разбросом от 94 до 130 % — внутри одной пачки, на дословно одинаковом тексте.
| Как просил | Что пришло |
|---|---|
| «около 18 градусов» | до упора |
| «на четверть ширины радужки» | 81 % от максимума |
| точка в 15 см от объектива + сдвиг в пикселях | 117 % |
| то же самое, с более широкого кропа | 124 % — и начало перерисовывать волосы и челюсть |
| «он смотрит в ваш второй глаз» | 74 % — попал! |
Последнее было похоже на рецепт. Я расписал его на все восемь направлений и запустил пачкой — и получил 111 %, с разбросом от 94 до 130 %, внутри одной пачки, на дословно одинаковой формулировке.
То есть 74 % были счастливым жребием внутри собственного разброса модели, а не ответом на мои слова.
Вывод, за который я заплатил двумя днями: направление генератор отдаёт, дозировку — нет. Вопрос закрыт, обходного пути нет.
Как это работает: спрайт глаз 201 × 114 поверх одного фото
Держать одиннадцать полных версий фотографии стоило бы 360 килобайт и 25 мегабайт памяти браузера — ради эффекта в аватарке. Вместо этого каждый кадр обрезан до одних глаз: прямоугольник 201 × 114 пикселей, который ложится поверх основного фото, как наклейка. Лицо, волосы, рубашка и стена загружаются один раз. Рот — вторая, независимая наклейка на 6,8 КБ.
-
Вверх-влево 216°
-
Вверх 274°
-
Вверх-вправо 332°
-
Влево 179°
-
Покой —
-
Вправо 354°
-
Вниз-влево 139°
-
Вниз 90°
-
Вниз-вправо 47°
Угол под каждым кадром — не тот, что просили в промпте, а измеренный по смещению радужки на уже собранном ассете. Поэтому компас ищет ближайшее из этих девяти настоящих состояний, а не делит круг на равные секторы.
Это не ровный восьмилучевой компас, хотя я просил именно такой. Диагонали завалены к горизонтали: на «вверх и влево» генератор отдаёт по большей части «влево». Я измерил все 45 сгенерированных кадров и назвал их второй раз, независимо от первого, — оба раза совпали. Выбор лучших из этих 45 вместо первых попавшихся сбил среднюю ошибку с 7,2° до 4,3°. Одно направление, «вверх-вправо», так и осталось с ошибкой 15°: в наборе просто нет кадра, который его обслуживает.
-
Покой —
Базовая плита. Ни одной заплатки сверху.
-
Полуприкрытые 86 / 88 %
Апертура век. Только в покое.
-
Закрытые 66 / 62 %
Апертура век. Без подмигивания.
-
Улыбка 6,8 КБ
Вторая заплатка со своей маской.
Глаза и рот — два независимых слоя над двумя независимыми участками, поэтому портрет улыбается, не переставая следить за курсором.
В этом и суть разделения на два слоя: слежение само по себе — это внимание, а слежение плюс улыбка — это уже приветствие.
Три случая, когда метрика сказала «да», а глаз — «нет»
Автоматическая проверка трижды выбрала кадр, который на экране выглядел неправильно. Она путала опускание века с движением зрачка, поэтому кадр с прищуренным глазом получал идеальный угол. Полоса прокрутки шириной 15 пикселей ломала взгляд только вправо. А шов через всё лицо нашли рецензенты, не я.
Человек, который щурился. Автоматическая проверка выбрала кадр «взгляд вправо» по идеальному углу — 1 градус от идеала. На экране это был человек, который щурится и едва смотрит вбок. Оказалось, проверка приняла опускание века за движение зрачка и подарила кадру ход, которого не было. Я трижды отбрасывал этот кадр на глаз, прежде чем додумался, что мерить надо другим методом.
Взгляд, который срывался только вправо. Симптом был конкретный: взгляд возвращался в центр каждый раз, когда я вёл курсор вправо. Причина — полоса прокрутки. Страница на 15 пикселей уже окна, курсор пересекает эту полоску, браузер решает, что мышь ушла со страницы, и глаза возвращаются в центр. Слева, сверху и снизу такой полоски нет — дефект мог появиться только справа.
Шов, которого я не видел, и чужие зубы. Я собрал улыбку и проверил её, увеличив втрое. Чисто. Тогда дал четырём рецензентам вслепую, не сказав, что есть что. Трое нашли одно и то же: жёсткий шов через всё лицо на уровне ноздрей — он шёл вдоль естественной тени под носом, поэтому я его и не заметил. А ещё они нашли то, чего не видел никто два дня: в предыдущем варианте улыбки были чужие зубы — ровные, белее, без моей настоящей щели.
Метрика сказала «да», глаз сказал «нет». Глаз прав.
Сколько весит на загрузке: 7,4 КБ вместо 46
На загрузке страница берёт 7 398 байт в двух запросах вместо 46 328 в одиннадцати. Остальное — 38 930 байт в девяти запросах — приходит только тогда, когда сдвинулась мышь. Портрет моргает независимо от того, пришёл ли курсор, но восемь кадров взгляда мертвы, пока мышь не сдвинется, а улыбка — пока кто-нибудь не потянется к ссылке.
| На загрузке | Потом, по необходимости | |
|---|---|---|
| Было | 46 328 байт, 11 запросов | — |
| Стало | 7 398 байт, 2 запроса | 38 930 байт, 9 запросов |
Посетитель с клавиатуры не тянет ни одного кадра взгляда. Телефон берёт только пару для моргания. Кто включил «уменьшить движение» (prefers-reduced-motion) — не берёт ничего.
Первое моргание: 0,7 секунды вместо 5,6
Первое моргание бралось из того же генератора случайных пауз, что и все остальные, — от 2,6 до 6,8 секунды. То есть увидит ли посетитель главный эффект вообще — решал бросок кубика в тот единственный момент, когда это значило больше всего.
Последнее, что оставалось, я сформулировал себе одним предложением: моргание должно случиться, пока человек ещё смотрит на портрет, а не когда он уже листает дальше.
Измерил на продакшене: кадры готовы на 0,5 секунды, страница догружается на 0,8, а первое моргание падало на 5,6 секунды. К этому времени человек уже прочитал заголовок и ушёл.
Теперь — 0,7 секунды после появления век. Один раз, не случайно. Дальше обычный ритм.
Пять выводов, которые переносятся на другие задачи
1. Работой был отсев, а не сборка. Отсеивалось и «как», и «что». Я хотел эффект, который заметят, и не был уверен, что сделаю такой сам, — поэтому сначала искал готовые библиотеки, примеры, плавные переходы одного изображения в другое. Семь направлений построено и выброшено, 37 кадров из 48 не пошли никуда. А задуманное оказалось проще и легче любого готового решения: 46 килобайт, без WebGL, без перекалибровки — и стоило оно ровно столько, сколько стоило закрыть остальное. Так же я считаю стоимость решений и на большем масштабе — в обзоре проектов та же арифметика записана в тысячах сайтов и миллионах инсталляций.
2. Смотрите в том масштабе, в котором это увидит человек. Плита на экране — 358 пикселей, глаз — 22. Дефект при трёхкратном увеличении и дефект в жизни — это два разных списка.
3. Ищите край, где среда устроена иначе. Взгляд срывался только вправо — не потому, что правая сторона тяжелее, а потому, что это единственный край с полосой прокрутки: страница на 15 пикселей уже окна. Слева, сверху и снизу такой полоски нет, так что больше нигде этот дефект и не мог появиться.
4. Сторонняя слепая проверка видит то, чего не видит автор. Четыре рецензента, которые не знали, что есть что, за пять минут нашли шов, который я лично проверял и пропустил. Это часть того, как я работаю, а не разовая мера.
5. Генератор выполняет «посмотри вбок», но не выполняет «насколько». Планируйте работу так, чтобы «немного» и «наполовину» вам не понадобились.
Что ещё не сделано: нижние веки в улыбке
Когда человек по-настоящему смеётся, у него поднимаются нижние веки. У моего портрета они остаются спокойными над поднятыми щеками. Починить это можно только перегенерировав все восемь кадров взгляда в улыбающемся состоянии — и снова играть в лотерею амплитуды.
Портрет живой. Он просто ещё не смеётся по-настоящему.
Остальные инженерные разборы — в блоге; если хотите обсудить что-то из этого, напишите мне.

