# Ошибки и защита от повторения — версия 2.3

> **Ворота не проверяют, что монтаж хороший.** Они проверяют, что файл не
> сломан: синхрон, длительность, громкость, шрифты, декодируемость хвоста.
> Ролик, прошедший все шесть, может быть скучным, с графикой на подбородке и
> двумя абстрактными перебивками вместо пятнадцати. Каждая из ошибок 21–26 ниже
> прошла ворота и была найдена только просмотром.

## 1. Субтитры совпадают с б-роллами, но не с речью

**Причина:** визуалы и титры построены на отдельном ручном таймере.

**Защита:** пословная транскрибация; все слои читают `timeline.json`; `validate_sync.py` сравнивает границы.

## 2. Субтитры начинаются через 5–8 секунд

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

**Защита:** глобальное время первого слова задаёт первый блок. Проверка кадров 0.0, первое слово и следующие три слова.

## 3. В начале появляются слова, которых автор не говорил

**Причина:** сценарий использован как транскрипт или `initial_prompt` содержит весь текст.

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

## 4. Второй исходник проигнорирован

**Причина:** сценарий выбирал самый большой видеофайл.

**Защита:** `discover_sources.py` сохраняет все кандидаты и останавливает автоматический выбор при нескольких файлах.

## 5. Второй файл добавлен, но фраза повторяется

**Причина:** второй дубль начинается с повторения последней фразы.

**Защита:** `plan_source_stitch.py` ищет лексическое совпадение хвоста и начала и вычисляет локальное время первого нового слова.

## 6. Финал обрывается

**Причины:** неверная корневая длительность, короткий GSAP-таймлайн, неиспользованный второй исходник, рендер до последнего титра вместо последнего кадра.

**Защита:** `expected_duration` из плана источников; отдельный рендер последних 5 секунд; `validate_full_end.py`; последняя операция GSAP до полной длительности.

## 7. Видео и голос идут параллельно

**Причина:** аудио и видео были вырезаны или сдвинуты разными командами.

**Защита:** один план склейки; один базовый клип/единый фильтр; запрет независимой ручной нарезки звука.

## 8. Картинка приплюснута

**Причина:** принудительная ширина и высота без сохранения пропорций.

**Защита:** `object-fit: cover`, вычисляемое кадрирование, проверка кругов/лиц и исходного соотношения сторон.

## 9. Статичные б-роллы выглядят мёртво

**Защита:** медленный масштаб 1.02–1.06, смещение до 4%, согласованный easing. Не использовать дрожание.

## 10. Контактный лист выглядит нормально, но синхронизация плохая

**Причина:** кадры сняты по круглым секундам, а не по словам.

**Защита:** кадры на точных метках первого слова каждого блока, стыках и последнем слове.

## 11. Кэш показывает старую версию

**Защита:** уникальное имя финала, проверка SHA-256 и длительности перед выдачей.

## 12. Двойные субтитры/перебивки

**Причина:** новый монтаж построен поверх старого экспорта.

**Защита:** только неизменяемые исходники из `source_inventory.json`.

## 13. Музыка скрывает рассинхрон

**Защита:** первая проверка выполняется без музыки и эффектов. Они добавляются после прохождения синхронизации.

## 14. Модель Whisper слишком тяжёлая

**Неправильно:** заменить её ручным распределением сценария.

**Правильно:** перейти с Medium/Small на Base, использовать CPU int8 или Whisper.cpp, но сохранить реальные временные метки.

## 15. Агент объявляет успех без проверки

**Защита:** финал запрещён до прохождения G0–G7. Отчёт содержит численные ошибки синхронизации и длительности.

## 16. Переход выходит цветным шумом, а формула правильная

**Причина:** регистры `st()`/`ld()` у выражения `xfade` общие для всех потоков
фильтра. Кадр режется на срезы, срезы считают одно и то же выражение
параллельно, и попиксельное значение в регистре затирается соседним потоком.

**Как выглядело:** «часть переходов сломана». `zoom_punch` работал, потому что
его `st()` зависит только от `Q` и одинаков для всех пикселей; `whip_pan` и
`glitch_digital` выдавали кашу, потому что кладут в регистр номер полосы и
смещённый `X`.

**Защита:** `render_transitions.py` и `render_cold_open.py` ставят
`-filter_complex_threads 1`, как только в плане есть custom-выражение. Ручная
проверка перехода без этого флага показывает шум — и уводит на несуществующую
ошибку в формуле.

## 17. Пресет говорит `opacity: 0.72`, а плашка непрозрачная

**Причина:** тег `\1c` несёт только цвет. Значение `&HAABBGGRR` выглядит так,
будто первый байт — альфа, но libass берёт из него BGR, а альфу оставляет из
стиля.

**Защита:** прозрачность задаётся `\1a`/`\3a`/`\4a`. Ошибка молчаливая: она
касалась плашки, подсветки слова, свечения и хроматической каймы одновременно, и
проявлялась только при сравнении рендера с пресетом.

## 18. Ворота говорят «первый титр не совпадает», а титры на месте

**Причина:** производные скаляры таймлайна (`first_word_start`, `last_word_end`,
`tail_after_speech`) скопированы из предыдущего таймлайна вместо пересчёта.
После холодного старта первое слово другое, а поле осталось прежним — сломан
таймлайн, а не синхронизация.

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

## 19. Мастер сдаёт −0.2 dBTP при цели −1.0

**Причина:** `alimiter` меряет **отсчётные** пики. Сигнал может лежать под
потолком на каждом отсчёте, а восстановленная волна между ними — выходить выше.
Именно это меряет ворота доставки и именно это клиппит транскодер платформы.

**Защита:** лимитирование на четырёхкратной частоте (`aresample=192000` →
`alimiter` → `aresample=48000`) плюс 0.3 дБ запаса на то, что четырёхкратная
передискретизация всё ещё не видит. `resampler=soxr` не просить: он собран не во
всех сборках ffmpeg и валит весь граф вместо отката на стандартный.

## 20. Контрольный кадр не извлекается из готового мастера

**Причина:** две ошибки сразу. `mjpeg` отказывается кодировать limited-range YUV
(«Non full-range YUV is non-standard»), а перемотка на
`expected_duration − 0.04` считается от **планируемой** длины: настоящий файл
обычно на кадр короче, и перемотка уходит за последний декодируемый кадр.

**Защита:** `-vf scale=out_range=pc,format=yuvj420p` для кадра и `-sseof -0.5`
с `-update 1` для последнего кадра — отсчёт от реального конца реального файла.

## 21. Блок субтитров кончается на «но», «по», «почти»

**Причина:** разбиение по счётчику слов. Блок закрывался, когда набиралось
`max_words`, независимо от того, на каком слове это случилось.

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

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

## 22. Фильтр молча не применяется или валит граф на выражении

**Причина:** выражения по времени принимает не всякий фильтр. `eq`, `hue`,
`crop`, `rotate` — принимают; `colorbalance`, `rgbashift`, `chromashift`,
`noise` — нет, у них обычные числа. Выражение в них падает не при разборе
графа, а при открытии фильтра: «Unable to parse option value».

**Вторая половина той же ловушки:** `eq` по умолчанию считает выражение **один
раз** и держит первое значение на весь файл. Нужен `eval=frame`. У `crop` такой
опции нет вовсе — его `x`/`y` и так пересчитываются каждый кадр, а `eval=frame`
валит граф с «Option not found».

**Защита:** плавные эффекты — на фильтрах с выражениями, скачкообразные — через
`enable=` на фильтрах с поддержкой таймлайна. И запятая внутри опции фильтра
читается парсером как разделитель фильтров, поэтому `max(0,x)` пишется как
`(x+abs(x))/2`.

## 23. Параметр есть в справке, а в графе его нет

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

**Защита:** отчёт обязан печатать то, что реально попало в граф, а не то, что
пришло в аргументах. Проверка простая: если значение параметра не встречается в
строке фильтра, параметр не применён.

## 24. Нарисованный своими руками эффект выглядит самодельно

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

**Защита:** декор берётся ассетом (Pixabay, бесплатные VFX-библиотеки —
материал с альфой или на чёрном фоне), а HyperFrames делает то, чего не купить:
графики по числам самого ролика. Сравнение двух измеренных величин не существует
ни в одном стоке и является содержанием, а не украшением.

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

## 25. Графика села на лицо, хотя зона была измерена

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

**Почему ворота молчали:** они проверяют синхрон, длительность, громкость и
шрифты. «Панель на подбородке» — это композиция, а не техника, и её видно только
глазами. Прохождение G0–G7 не означает, что монтаж хороший; оно означает, что
файл не сломан.

**Защита:** `render_graphics_overlay.py` меряет полосу спикера в окне ±0.5 с
вокруг каждого события. Плюс доля лица поднята с 0.42 до 0.50 полосы и добавлен
зазор 2% высоты: первый рендер поставил панель в 50 пикселях под подбородком,
который на деле был на 90 пикселей ниже расчёта.

## 26. Музыки не слышно, но её биты перебивают голос

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

**Защита:** `audition_music.py` считает плотность резких атак выше 5 кГц. Выше
2.5 в секунду — трек будет слышен щелчками. `mix_music_bed.py` дополнительно
вешает полку `treble` до −7 дБ от 6 кГц пропорционально измеренному.

Реальный пример: трек с 3.1 транзиента/с и 172 BPM заменён на 1.7 и 68 BPM.

## 27. Возобновление пропустило шаг, а следующий упал на его файле

**Симптом:** `--from` продолжает прогон, шаг помечен «пропущен, уже свежий», а
следующий падает с `moov atom not found` или `Invalid data found` про файл,
который этот шаг якобы сделал.

**Причина:** свежесть проверялась по **наличию** файла и дате. Убитый посреди
записи ffmpeg оставляет контейнер без moov-атома: сто с лишним мегабайт,
правдоподобная дата, нечитаемое содержимое. Для проверки «файл на месте и новее
входов» это готовый результат.

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

**Защита:** `run_pipeline.usable()` спрашивает у видео и звука длительность через
`ffprobe`. Дёшево — читается только заголовок — и отсекает весь класс. Шаг с
битым выходом пересобирается, и в отчёте появляется `rebuilt_because` с
причиной, а не молчаливое «пропущен».

**Правило шире одного скрипта:** существование файла не есть его готовность.
Везде, где шаг решает «это уже сделано», решение принимается по содержимому.

## 28. Правило записано в документации, а дефолт указывает в другую сторону

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

**Причина:** `render_graphics_overlay.PACK` продолжал указывать на рисованный
пак, а конвейер не передавал `--pack`. Правило существовало только в тексте.

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

**Правило шире одного случая:** решение, записанное в документации, но не
отражённое в значении по умолчанию, не принято. Проверять надо код, а не текст.

## 29. Слой не попал в мастер, и никто не заметил

**Причина:** `state["picture"]` обновляется, когда шаг отрабатывает. При
`--from music` шаги burn/graphics/broll/layers в выборку не попадают, и картинка
оставалась на значении по умолчанию — музыка легла бы на `captioned.mp4`, молча
выбросив графику и звуковой слой.

**Чем это опасно:** файл собирается, ворота проходят, длительность совпадает.
Отсутствует только половина работы.

**Защита:** при старте цепочка восстанавливается по факту — берётся последний
существующий и **открывающийся** выход из `captioned → with_gfx → with_broll →
with_layers`. В отчёт добавлено поле `picture_chain_ends_at`.

## 30. Дрейф длительности копится по проходам

**Причина:** каждый проход укладывался в свой допуск (80 мс), но семь проходов
дали 114 мс, и ворота доставки справедливо упали. Допуск на шаг не складывается
в допуск на цепочку.

**Где именно:** трассировка длительности по всем файлам показала, что весь
провал даёт **один** шаг — слой звука. Там видео копируется, но `-shortest`
заканчивает файл по самому короткому потоку, а AAC квантует по 1024 отсчёта, и
обрезалось скопированное видео.

**Защита:** `apad` перед `atrim` и никакого `-shortest`. Звук заведомо не короче,
обрезается ровно по картинке.

**Метод, а не только починка:** при расхождении длительности первым делом
измеряется длительность **каждого** промежуточного файла. Дрейф почти никогда не
размазан — он весь в одном шаге.

## 31. Субтитры пропадают ровно на перебивках

**Симптом:** титры стоят везде, кроме моментов вставок. На каждой перебивке
текст исчезает и появляется обратно.

**Причина:** перебивки накладывались **после** прожига титров. Полноэкранная
вставка перекрывает ровно ту область, где стоит плашка субтитра.

**Почему ворота молчали:** покрытие слов считается по ASS-файлу, а не по
пикселям. Титр присутствует в файле субтитров, значит покрытие 100%. То, что он
закрыт картинкой, не видит ни одна проверка.

**Защита:** порядок слоёв снизу вверх — база → перебивки → титры → графика, то
есть в конвейере `cutfx → broll → burn → graphics`. Шаг прожига берёт последнюю
картинку из цепочки, а не жёстко `with_fx.mp4`, иначе включённые перебивки молча
пропадут в обратную сторону.

**Правило:** всё, что должно быть видно поверх, композитится позже. Титры выше
перебивок всегда — иначе они не субтитры, а иногда-субтитры.

## 32. Готовый файл невозможно найти среди промежуточных

**Причина:** к концу монтажа рабочая папка держит полтора десятка MP4 по сто с
лишним мегабайт, различающихся только именем шага. Через неделю не вспомнить,
чем `with_gfx2` отличался от `with_gfx`.

**Защита:** `deliver.py` собирает отдельную папку `deliveries/<имя>/` — ролик,
контактный лист, субтитры, планы, отчёты. Версия нумеруется автоматически,
README дописывается и хранит историю. Не прошедшее ворота не выдаётся без
`--force`, и тогда README это фиксирует.

## 28. «Оверлей» со стока оказался обычной съёмкой

**Причина:** Pexels и Pixabay — библиотеки съёмки. По запросу «arrow overlay»
они честно отдают видео, где кто-то снял улицу. Из восьми скачанных «оверлеев»
настоящими элементами оказались два.

**Защита:** `render_element_overlay.py --inspect` определяет по самому файлу,
чем он является: альфа из `pix_fmt`, фон по цвету **рамки кадра**. Обычная
съёмка отклоняется с объяснением, а не накладывается непрозрачным прямоугольником.
Настоящие элементы берутся с профильных VFX-сайтов руками — API у них нет.

## 29. Один б-ролл на весь ролик — это не б-ролл

**Причина:** вставки ставились вручную, поэтому их было столько, сколько хватило
терпения. Две штуки на сто секунд не меняют ощущение статичности вообще.

**Защита:** `harvest_broll.py` берёт запросы из **произнесённых слов**, идёт по
таймлайну с шагом 5–7 секунд и собирает столько кандидатов, сколько нужно
слотам. Ставить их всё равно решает человек по кадрам: материал, выбранный по
тегам, уже дважды пришлось выбрасывать после рендера.

## 26. Б-ролл найден по запросу и не подходит монтажу

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

**Защита:** смотреть кадры вставки до сборки, а не только теги и лицензию.
Критерии годности б-ролла: ориентация под канвас, отсутствие чужого текста в
кадре, заполненность кадра содержанием. Не подошло — вставка не ставится, а
окно закрывается рефреймом.
