# Переходы и звук переходов

30 рецептов в `assets/transitions/transition_library.json`, 22 синтезированных звука
в `assets/sfx/`. Все 30 переходов прогоняются рендером с нулевым расхождением
длительности.

## Доктрина

1. Переход не создаёт смысл, а обслуживает уже найденный стык.
2. Прямая склейка — вариант по умолчанию и стоит **0** очков энергии. Всё остальное
   надо обосновать.
3. Звук ставится ДО визуального события: пик SFX совпадает с точкой склейки.
4. Один и тот же переход не идёт дважды подряд, три перехода одной семьи подряд
   читаются как шаблон.
5. Бюджет энергии на минуту ограничен (по умолчанию 14).
6. Переход, который гасит или подменяет картинку (в чёрное, растворение, расфокус),
   ставится только в паузу речи.
7. Переход длиннее 20 кадров в вертикальном ролике читается как ошибка рендера.

## Арифметика, которую нельзя пропустить

`xfade` **перекрывает** два клипа, поэтому каждый переход укорачивает результат на
свою длительность:

```
итог = сумма(длительности клипов) − сумма(длительности переходов)
```

`scripts/render_transitions.py` считает это, пишет `cut_points` — где каждая склейка
оказалась на выходной шкале — и проверяет фактическую длительность против расчётной.
**Субтитры и музыка обязаны читать `cut_points`, а не исходные времена клипов.**

Переход не может быть длиннее 60% более короткого из соседних клипов; в этом случае
он укорачивается, и это попадает в `adjustments`.

## Custom-выражения: пять ловушек ffmpeg

Тринадцать рецептов (`zoom_through`, `zoom_punch`, `whip_pan`, `glitch_digital`,
`flash_punch`, `rgb_glitch`, `strobe_cut`, `shake_impact`, `zoom_blur_punch`,
`block_shuffle`, `roll_glitch`, `light_leak`, `film_burn`) сделаны через
`xfade=transition=custom`, потому что встроенные варианты дают только кроссфейд с
намёком на движение. Ловушки, каждая проверена рендером:

### `P` идёт от 1 к 0

Переменная прогресса в `xfade` — это вес **исходящего** кадра, а не пройденная доля.
Рецепты пишутся через `Q` (прямой прогресс 0→1), подстановку `Q → 1−P` делает
`scripts/transition_lib.py`.

### `a0()` всегда читает плоскость яркости

`a0(x,y)`/`b0(x,y)` берут только плоскость 0. Использованные напрямую, они кладут
яркость в цветовые плоскости, и переход выходит зелёно-пурпурным. Рецепты пишут
`A@(x,y)` / `B@(x,y)`, а `transition_lib` разворачивает это в выбор по `PLANE`.

### `X`/`Y` идут по плоскости, `W`/`H` — размер кадра

В 4:2:0 цветовые плоскости вдвое меньше, поэтому геометрия, написанная через `W`/`H`,
выбирает пиксели далеко за границей плоскости и упирается в её край. Custom-переходы
рендерятся в `yuv444p` и возвращаются в `yuv420p` — это дороже, но снимает
расхождение вместо того чтобы размазывать делители на два по всем рецептам.

### Регистры `st()`/`ld()` общие для всех потоков фильтра

Самая дорогая из пяти, потому что она не падает — она рисует шум и выглядит как
ошибка в формуле.

`xfade` режет кадр на горизонтальные срезы и считает **одно и то же**
скомпилированное выражение в нескольких потоках. Набор регистров `st()`/`ld()` у
этого выражения один. Значит, рецепт, который кладёт в регистр **попиксельную**
величину — номер полосы, смещённый `X`, — получает её затёртой соседним потоком
между записью и чтением, и кадр выходит цветным шумом.

Рецепты, у которых `st()` зависит только от `Q`, переживают это незаметно:
значение одинаково для всех пикселей, поэтому гонка не видна. Именно поэтому
поломка выглядела как «часть переходов сломана»: `zoom_punch` работал,
`whip_pan` и `glitch_digital` выдавали кашу.

`scripts/render_transitions.py` и `scripts/render_cold_open.py` ставят
`-filter_complex_threads 1`, как только в плане есть хоть одно
custom-выражение. **Проверяя переход вручную, ставь тот же флаг** — иначе
увидишь шум и решишь, что виновата формула.

### Плоская заливка задаётся по плоскости

Белая точка — это `if(eq(PLANE,0),255,128)`, а не `255`. Одна и та же константа
во всех трёх плоскостях даёт не вспышку, а зелёно-пурпурный кадр: 255 в `U` и
`V` — это угол цветового куба, а не белый. Тёплый оранжевый для засветки —
`if(eq(PLANE,0),255,if(eq(PLANE,1),95,175))`.

## Семьи переходов

| Семья | Смысл | Примеры |
|---|---|---|
| `cut` | Без эффекта, смысл несёт монтаж | `cut`, `cut_sfx`, `match_cut` |
| `motion` | Сдвиг кадра, направление читается как смысл | `push_soft`, `whip_pan`, `slide_hard` |
| `zoom` | Изменение масштаба, вход внутрь | `zoom_through`, `zoom_punch` |
| `light` | Экспозиция и растворение, время и настроение | `flash_white`, `flash_black`, `fade_soft` |
| `mask` | Перекрытие плоскостью, карточки и темы | `cover_left`, `reveal_right` |
| `dimensional` | Псевдо-3D через сжатие кадра | `squeeze_card`, `cube_vertical` |
| `mechanical` | Затвор, ирис, радиальная развёртка | `iris_open`, `radial_sweep` |
| `digital` | Глитч, пиксель, полосы | `glitch_digital`, `pixelize` |
| `organic` | Плёнка, расфокус | `tape_stop`, `blur_dissolve` |

`squeeze_card` и `cube_vertical` названы честно: это сжатие кадра, а не настоящий
`rotateY`. Для истинного 3D-поворота нужен слой HyperFrames.

## Планировщик

```bash
python scripts/plan_transitions.py recut_plan.json \
  --timeline timeline_recut.json --intent dynamic --out transition_plan.json
```

Интенты: `calm`, `editorial`, `dynamic`, `tech`, `premium`, `street`. Планировщик
держится в бюджете энергии, чередует рецепты, ставит громкие переходы только в паузы
речи и объясняет каждое решение полем `why`.

Поверх произносимого слова разрешены только переходы с энергией ≤ 2. Это не
формальность: заметный переход на слове глушит само слово.

## Звуковой пак

22 звука синтезируются локально из трёх ингредиентов: шумовая полоса со свипом,
тональный элемент с изгибом, огибающая с пиком на склейке. Синтез выбран не из
экономии:

* права заведомо чистые и проверяемые;
* пак побайтово воспроизводим при том же `--seed`;
* длительность параметризована, поэтому whip на 9 кадров и push на 16 получают звук
  нужной длины, а не одну библиотечную запись, грубо подрезанную.

Генератор сам себя проверяет и падает, если звук не соответствует заявленному:
пик в диапазоне −3…−0.5 dBFS, крест-фактор ≤ 22 дБ для протяжённых жестов, райзеры
пикуют в последних 45% своей длины, транзиенты — в первых 30%.

Первая версия пака проходила все функциональные тесты, выдавая при этом щелчки:
overlap-add нормализация взрывалась на краях буфера и сгоняла всю энергию в первую
миллисекунду. Ловит это только проверка крест-фактора.

## Уровень SFX считается от голоса

Громкость каждого звука измеряется по самому громкому окну 200 мс и приводится к
измеренной громкости фонограммы. Выравнивание по пику не работает: свуш с
крест-фактором 16 дБ на слух тише удара с тем же пиком — половина библиотеки
оказывается не слышна.

```bash
python scripts/mix_sfx_track.py video.mp4 transition_report.json out.mp4 \
  --sfx-offset-db -7 --bus-out bus.wav
```

`--sfx-offset-db` — где стоит самый громкий звук относительно громкости голоса.
`--bus-out` пишет шину отдельно, чтобы можно было измерить реальную глубину дакинга.

`--duck-ratio` — это **коэффициент компрессии**, а не количество дБ подавления.
Насколько шина реально просядет, зависит от того, насколько ключевой сигнал
превышает порог, поэтому достигнутая глубина измеряется по факту, а не постулируется.

Больше двух звуков в окне 180 мс не ставится: три свуша подряд суммируются в шум.
Отброшенные реплики попадают в `dropped_for_crowding`.

## Синхронизация звука с картинкой

```text
SFX start        Visual start       Cut/peak       Tail
    |-----------------|----------------|----------|
          -2..-5f            0f            +4..+10f
```

`offset_frames` в библиотеке отсчитывается от точки склейки; отрицательное значение
означает, что звук начинается раньше картинки. Райзер с пиком в конце стартует за
6–10 кадров до стыка.

Проверяй в наушниках и на динамике телефона. Если эффект слышен как отдельная
библиотечная кнопка — он слишком громкий или не совпадает с движением.
