# AI Pro Video Production 4.1

Разбор одного реального разговорного исходника (`IMG_1320.MP4`, 112.9 с, телефон,
комната) и починка того, что этот разбор вскрыл. Каждое изменение ниже вызвано
либо измерением этого файла, либо ошибкой, найденной при его сборке.

## Четыре ошибки движка, которые молчали

Все четыре не падали. Они выдавали неправильный результат, который выглядел как
чей-то чужой промах.

**1. Custom-переходы рендерились шумом.** Регистры `st()`/`ld()` у выражения
`xfade` общие для всех потоков фильтра: кадр режется на срезы, срезы считают одно
и то же выражение параллельно, и попиксельное значение в регистре затирается
соседним потоком. Рецепты, у которых `st()` зависит только от `Q`, переживают
это незаметно, поэтому поломка выглядела как «часть переходов сломана» —
`zoom_punch` работал, `whip_pan` и `glitch_digital` давали кашу. Рендеры теперь
ставят `-filter_complex_threads 1` при любом custom-выражении.

**2. Прозрачность в субтитрах игнорировалась.** Тег `\1c` несёт только цвет:
байт альфы из `&HAABBGGRR` он отбрасывает, оставляя альфу стиля. Пресет с
`opacity: 0.72` рисовал непрозрачную плашку, с `opacity: 0.0` — непрозрачную
заливку вместо ничего. Касалось плашки, подсветки слова, свечения и хроматической
каймы одновременно. Теперь `\1a`/`\3a`/`\4a`.

**3. Мастер выходил выше потолка платформы.** `alimiter` меряет отсчётные пики;
сигнал лежит под потолком на каждом отсчёте, а волна между ними уходит выше.
Ворота доставки честно ловили −0.2 dBTP при цели −1.0. Лимитирование переведено
на четырёхкратную частоту плюс 0.3 дБ запаса → −1.3 dBTP.

**4. Контрольные кадры не извлекались из готового мастера.** `mjpeg` отказывается
кодировать limited-range YUV, а перемотка на `expected_duration − 0.04` считалась
от планируемой длины и уходила за последний кадр реального файла. Оба случая
чинились годами вручную, потому что ffmpeg не писал в лог ничего осмысленного.

Плюс пятая, моя собственная, найденная воротами: `render_cold_open.py` копировал
производные скаляры таймлайна вместо пересчёта, и ворота сообщали «первый титр не
совпадает» про совершенно синхронный файл.

## Что добавлено

### Речь

`voice_restore.py` — починка записи по измерениям, до всякой стилизации. Сетевая
наводка ищется как пик над своим окружением, а не срезается фильтром. Тональный
баланс сравнивается с долговременным средним спектром речи: величина «на сколько
децибел этот голос отличается от того, как звучит речь вообще» имеет смысл, а
отношение сумм двух полос — нет, оно говорит о ширине полос. Спектр считается на
окне 8192, потому что на 1024 полоса 125 Гц не содержит ни одного бина и читается
как тишина.

На этом исходнике: SNR 25.2 → 38.3 дБ, шумовой пол −42 → −56.6 дБFS, бубнение
+7.6 → +4.5 дБ, согласные −9.9 → −5.9 дБ.

### Удержание

`plan_retention.py` + `render_punch_ins.py`. Статичная говорящая голова держит
~41% зрителей, та же длина со сменой картинки каждые 3–5 секунд — ~58%. Ничто
другое в пайплайне не двигает удержание на семнадцать пунктов. Планировщик
считает бюджет неподвижности; чем закрыть окно, решает транскрипт.

На этом ролике: средний интервал между сменами 5.04 → 3.31 с, максимальный
10.15 → 4.80 с, доля хронометража сверх бюджета 81% → 37%.

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

### Холодный старт

`plan_cold_open.py` + `render_cold_open.py`. Фраза выбирается по уровню над
медианой этого спикера, размаху основного тона и движению в кадре относительно
медианы файла. Хвост обесценивается: там живут вывод и призыв, и перенос их в
начало покупает открытие ценой финала. Это ранжирование, а не выбор — акустика
измеряет подачу, но не смысл, и рекламная строка стабильно выигрывает у хорошего
крючка.

### Музыка

`audition_music.py` ранжирует треки по тому, что мешает речи. Сильнейший
предиктор — доля энергии в 300–3400 Гц: трек с плотной серединой приходится
задавить до неслышимости, поэтому «музыки не слышно» и «музыка мешает» — одна
проблема. Трек из предыдущей версии монтажа получил 62 из 100: клиппован и
**интро 69 секунд** при длине ролика 105. Заменивший его — 100.

`mix_music_bed.py` делит бас на три полосы и давит каждую за то, что она делает.
Плоский сайдчейн даёт единственную ручку «насколько закопать» и уносит бас с
воздухом вместе с серединой, которая одна и была проблемой.

### Вставки с YouTube

`youtube_source.py`. Поиск через собственный CC-фильтр YouTube, отказ скачивать
не-CC, скачивание только нужных секунд, провенанс и готовые строки атрибуции.
Флаг `--i-hold-the-rights` пишет в манифест, что права заявлены оператором, а не
проверены.

### Дизайн

Шесть пресетов субтитров, базовым стал `signature_plate`: скруглённая плашка на
кубических безье, хватающаяся за измеренную ширину строк. Обводка не нужна, буквы
остаются чистыми.

Девять переходов семьи вспышек и глитчей, десять новых синтезированных звуков,
четыре плёночных грейда, собранных методом колориста (носок и плечо кривой →
перекрёстное влияние каналов → расщепление тона → лепка насыщенности), а не
подстановкой LUT.

## Второй круг: то, что нашлось при просмотре готового ролика

Ворота качества прошли все шесть проверок, а ролик всё равно смотрелся плохо.
Это ровно тот случай, ради которого существует просмотр: ворота проверяют, что
файл технически корректен, а не что он хорошо смонтирован.

**Субтитры резались по счётчику слов.** Блок закрывался на четвёртом слове,
каким бы оно ни было, и примерно каждый пятый заканчивался на предлоге или
союзе: «распознавать изображения, **но**», «строю собственный движок **по**».
Такие слова цепляются вперёд, и строка читается как оборванная. Заменено на
двухстадийное разбиение: поток режется только там, где речь останавливается, а
внутри фразы точки разрыва перебираются динамическим программированием. На том
же транскрипте — **ноль** блоков на цепляющемся слове против прежних заметных.
Перебор детерминирован: один транскрипт даёт одну разбивку всегда.

**Переходов в ролике не было вообще.** Девять новых рецептов были построены и
проверены покадрово, но применить их к собранному монтажу нельзя: `xfade`
требует разрезать файл обратно на клипы, а вместе с ними теряются скоростные
рампы. Добавлен `render_cut_effects.py` — эффекты навешиваются на уже
существующие стыки одним проходом. Тип выбирается по причине стыка: вдох не
является сменой мысли.

**Музыка была слишком тихой.** 19 LU под голосом — цифра из вещательной
практики, но она предполагает подложку, которая играет **между** фразами. В
плотно нарезанной говорящей голове этих пауз нет — они вырезаны, — поэтому та же
цифра воспринимается на несколько дБ тише, чем показывает прибор. Рабочее
значение снижено до 15 LU.

**`--outro-swell` был мёртвым параметром.** Разбирался, попадал в отчёт и никогда
не доходил до фильтра. Отчёт при этом честно писал, что подъём применён.

**Ещё две ловушки ffmpeg**, каждая стоила отдельной отладки: выражения по времени
принимает не всякий фильтр (`colorbalance`, `rgbashift`, `chromashift`, `noise` —
нет, и падают на открытии, а не на разборе), а `eq` по умолчанию считает
выражение один раз и держит первое значение на весь файл.

**`run_pipeline.py`** — весь монтаж одной командой. Порядок шагов зашит в код,
потому что его нарушение даёт тихую ошибку: титры, собранные до холодного
старта, уезжают на полторы секунды и никто об этом не сообщает. Шаг со свежим
выходом пропускается, упавший прогон продолжается с места падения, шаг с кодом 0
без заявленных файлов считается упавшим.

## Инвентарь

| | было | стало |
|---|---|---|
| скрипты | 55 | 63 |
| пресеты субтитров | 25 | 31 |
| переходы | 30 | 39 |
| звуки | 22 | 32 |
| грейды | 12 | 16 |
| справочники | 26 | 30 |
