# Слой графики: HyperFrames поверх готовой базы

## Главный вывод, купленный одним выброшенным паком

**HyperFrames — для данных, а не для декора.**

Первый пак был рисованными эффектами в стиле cel-shading: стрелки, звёздочки,
линии скорости, дым. Технически он работал — альфа, покадровая проверка,
измеренное размещение. На готовом ролике он выглядел самодельно, и его пришлось
снять целиком.

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

А вот чего готовым не взять — графики по **твоим** числам. Сравнение 15,3%
против 26,2% из этого ролика не существует ни в одном стоке, и именно оно —
содержание видео, а не украшение. Здесь HyperFrames незаменим, и результат сразу
выглядит дорого, потому что дорого выглядит правда, поданная точно.

Правило: **декор — ассетом, данные — композицией.**

## Что делает пак данных

`assets/hyperframes/dataviz/` — три карточки:

| Слот | Что | Куда ставится |
|---|---|---|
| `year` | Год отдельной карточкой | На произнесённую дату |
| `hardware` | Модель, спецификация | На произнесённое название |
| `comparison` | Два числа столбиками | На **первую** из двух цифр |

Длины столбиков пропорциональны настоящим значениям — короткий составляет 58%
длинного, потому что 15,3 составляет 58% от 26,2. График, у которого столбики
выбраны «чтобы смотрелось», — это ложь, сказанная линейкой.

Три детали, без которых карточка выглядит дёшево:

- **`font-variant-numeric: tabular-nums`.** Без него считающееся число
  переверстывается каждый кадр, и панель дёргается.
- **`scaleX`, а не анимация `width`.** Масштаб считает GPU; ширина пересобирает
  раскладку каждый кадр, и на 1080p это видно как дрожание края.
- **`backdrop-filter` не работает.** При рендере с альфой за панелью прозрачные
  пиксели — размывать нечего, и стекло выходит плоским серым. Нужна сплошная
  заливка процентов на 80.

Шрифт — тот же, что у субтитров. Иначе ролик читается как два дизайна,
положенных друг на друга. Файлы шрифта лежат **внутри** проекта: рендер
разрешает пути от своего корня, и `../../` молча уходит в никуда со сваливанием
на системный шрифт. `hyperframes check` ловит это как
`invalid_parent_traversal_in_asset_path`.

## Слоты живут рядом с паком

`<pack>.slots.json` описывает, где какая карточка лежит во времени и на каком
`cy` она нарисована. Пак — это ассет; добавление карточки не должно быть правкой
скрипта компоновщика.

```bash
# один раз — собрать пак (12 с, 8 эффектов, альфа-канал)
cd assets/hyperframes/street_overlays
hyperframes check .
hyperframes render . --format mov --output ../street_overlays_pack.mov

# на каждом проекте — разместить и наложить
python scripts/find_overlay_zone.py captioned.mp4 --canvas reels --out overlay_zone.json
python scripts/render_graphics_overlay.py captioned.mp4 with_gfx.mp4 \
  --retention-plan retention_plan.json --zone overlay_zone.json
```

## Стиль

Референс — Need for Speed Unbound: рисованная граффити-анимация поверх
фотореала. Ключевые признаки, которые и делают её узнаваемой:

- **плоская заливка с жирным тёмным контуром** — cel-shading. В пакете это
  всегда два наложенных пути: толстый почти-чёрный снизу, цветной сверху.
  Один путь с фильтром обводки размылся бы; два стоящих друг на друге остаются
  резкими на 1080p и читаются как тушь;
- **ограниченная палитра на тёмном** — маджента `#FF2D87`, кислотный жёлтый
  `#E8FF3A`, циан `#2FE6FF`, белый, контур `#101014`;
- **комиксные линии скорости и рисованный дым** — не блики и не частицы;
- **эффект появляется рисованием**, а не проявлением. Каждый штрих раскрывается
  по своей длине через `strokeDashoffset` — именно это отличает нарисованное от
  выцветшего.

## Почему пак — один файл, а не восемь

Все восемь эффектов лежат в одной композиции подряд, каждый в своём слоте по
1.5 секунды. Компоновщик режет нужный слот по времени.

Восемь отдельных композиций — это восемь запусков Chrome и восемь кодирований.
Одна полоса — один рендер (1 мин 38 с на 8 ядрах) и один ProRes 4444 с настоящей
альфой.

## Зона меряется на каждом событии, а не один раз на файл

`find_overlay_zone.py` усредняет весь клип. Для неподвижной камеры это верный
ответ. После `render_punch_ins.py` — неверный: крупность меняется несколько раз
в минуту, поэтому панель, поставленная по среднему, оказывается на груди в общих
планах и **на подбородке** в крупных. Именно так карточка сравнения въехала
спикеру в лицо на готовом мастере, пройдя при этом все ворота качества.

Замер в окне ±0.5 с вокруг события стоит одного вызова ffmpeg и снимает весь
класс ошибки. Отчёт по каждой вставке пишет измеренные границы лица и куда её
увели:

```
43.10s comparison: лицо 498..1035, свободно 0..486 → 243
```

Доля полосы спикера, считающаяся лицом, — **0.50**, а не 0.42, с которых это
начиналось. 0.42 примерно верно на среднем плане, но панч-ин опускает подбородок
ниже по полосе, и первый рендер поставил панель в 50 пикселях под подбородком,
который на деле был на 90 пикселей ниже расчёта. Защита лица, верная только при
средней крупности, защитой не является. Сверху добавлен зазор в 2% высоты кадра:
графика не должна даже касаться подбородка.

Субтитры владеют низом кадра — `--caption-top` (по умолчанию 0.74 высоты).
Панель, обошедшая лицо и севшая на собственные титры, не решила ничего.

## Размещение измеряется

`find_overlay_zone.py` возвращает строки кадра, занятые спикером. Дальше:

1. **Защищается лицо, а не вся фигура.** `subject_rows` охватывает от макушки до
   пояса. Если считать запретной всю эту полосу, на вертикальном кадре остаётся
   334 пикселя сверху — и все эффекты сваливаются в одну кучу наверху. Голова —
   верхние ~42% полосы спикера; кольцо на груди и стрелка к титру не закрывают
   ничего, на что зритель смотрит.
2. **Указывающий эффект не переносится, а снимается.** Стрелка, перенесённая
   с того, на что она показывала, — уже не стрелка. То же правило, что и у
   б-роллов: не подошло — не ставим.
3. **Не влезающий по высоте — масштабируется, а не выбрасывается.** Меньшая
   вспышка — всё ещё вспышка, и акцент, который запросил монтаж, сохраняется.
4. **Снятый эффект теряет слот, но не момент** — подставляется компактный
   ненаправленный, потому что монтаж всё равно хотел акцент в этой точке.

Арифметика сдвига учитывает масштаб. `overlay` центрирует уменьшенный кадр пака,
поэтому при `scale = s` элемент оказывается на `(H − H·s)/2 + cy·s`, а не на
`cy`. Считать сдвиг как `target − cy` верно только при `s = 1`; на полосе в
334 пикселя ошибка — это разница между свободной полосой и лбом спикера.

## Ловушка HyperFrames: затухание без жёсткого сброса

Линтер `hyperframes check` ловит `gsap_exit_missing_hard_kill`, и это не
придирка. Рендер **перематывает** на произвольный кадр, а не проигрывает вперёд.
Кадр, попавший сразу за анимацией затухания, получает ту непрозрачность, которую
интерполяция записала последней, — и следующий слот наследует призрак
предыдущего эффекта.

После каждого выхода обязателен `tl.set(selector, { opacity: 0 }, граница)`.

## Детерминированность

Линии скорости и клубы дыма расставлены псевдослучайно, но генератор — свой, с
зашитым зерном. `Math.random()` дал бы новый пак на каждом рендере, а пак,
который не совпадает с предыдущим, бесполезен монтажу, уже собранному под него.

## Что ещё умеет этот слой

`retention_plan.json` — и есть план графики. События `kind: "callout"` помечают
моменты, где произносится число: его нужно **видеть**, а не только слышать, и
это работа графики, а не субтитра. События `kind: "reframe"` — точки, где монтаж
меняет крупность и может нести акцент.
