# Конвейер: один порядок, который работает

```bash
python scripts/run_pipeline.py --project PROJECT \
  --source source.mp4 --words transcripts/source_001.words.json \
  --music assets/music/track.mp3
```

## Почему это отдельный скрипт, а не абзац в инструкции

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

| Нарушение | Что происходит | Что видно |
|---|---|---|
| титры собраны до холодного старта | весь файл уезжает на длину хука минус длина перехода | ничего, пока не посмотришь |
| панч-ины после прожига титров | субтитры дышат вместе с зумом | «дрожат буквы» |
| громкость нормализована дважды | мастер выше потолка платформы | клиппинг после загрузки |
| план удержания читает старый таймлайн | события падают не на те слова | «зумы невпопад» |

Поэтому порядок зашит в код, а не описан прозой.

## Порядок слоёв картинки

Это отдельное правило, и его нарушение стоило готового ролика.

```
графика      ← самый верхний
титры
перебивки
база (речь)  ← самый нижний
```

Композиция идёт снизу вверх, поэтому в конвейере: `cutfx → broll → burn → graphics`.

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

Признак в готовом файле: субтитры есть везде, кроме моментов перебивок.

## Порядок и почему он такой

```
voice      voice_restore        звук чинится ДО нарезки: после ретайминга шум
                                растянут вместе с речью и уже не отделяется
recut      plan_speech_recut    нарезка строит шкалу, все читают её
base       render_speech_recut  базовый рендер со скоростными рампами
hook       plan_cold_open       ранжирование крючка (выбор — за человеком)
open       render_cold_open     ЗДЕСЬ шкала сдвигается. Дальше её никто не двигает
retention  plan_retention       бюджет неподвижности по ИТОГОВОЙ шкале
punch      render_punch_ins     рефреймы ДО титров
grade      apply_grade          грейд по готовой геометрии
cutfx      render_cut_effects   эффекты на существующих стыках
broll      render_broll_overlay перебивки ДО титров, иначе они их закроют
captions   build_word_synced_ass
burn       burn_ass_captions    прожиг поверх базы с перебивками
dataviz    plan_dataviz         числа из речи -> карточки данных
zone       find_overlay_zone    где спикер, чтобы графика не села на лицо
graphics   render_graphics_overlay  самый верхний слой
layers     render_audio_layers  атмосфера и J-cut, ДО музыки
music      mix_music_bed        музыка под готовый голос
master     audio_fx             громкость нормализуется ОДИН раз, здесь
gates      run_quality_gates    без них файл не готов
```

## Свойства прогона

**Возобновляемость.** Шаг, у которого выход новее входов **и открывается**,
пропускается. Упавший прогон продолжается с места падения:

```bash
python scripts/run_pipeline.py --project PROJECT --from burn
```

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

**Отказ вместо тишины.** Шаг, завершившийся с кодом 0, но не создавший
заявленные файлы, считается упавшим. Молчаливое «вроде отработало» — это то, ради
чего вторая аксиома скилла вообще написана.

**Отчёт как транзакция.** `pipeline_report.json` хранит по каждому шагу команду,
время выполнения и его собственный JSON-отчёт с числами. Законченный прогон
воспроизводится из одного этого файла.

**Сухой прогон.** `--dry-run` печатает план и точные команды, ничего не запуская.
`--list` печатает, что за чем идёт и почему.

## Что настраивается

| Ключ | Значение по умолчанию | Про что |
|---|---|---|
| `--caption-preset` | `signature_plate` | стиль субтитров |
| `--grade` / `--grade-strength` | `kodak_portra_skin` / 0.85 | цвет |
| `--speed` / `--recut-style` | 1.05 / `balanced` | плотность нарезки речи |
| `--hook-pick` | 0 | 0 — выбор скрипта, 1..N — из shortlist |
| `--max-static` | 4.0 | потолок неподвижности, секунды |
| `--fx-intent` | `dynamic` | семья эффектов на стыках |
| `--music-intent` | `under_talk` | насколько музыка присутствует |
| `--loudness` | `reels` | целевая громкость платформы |

## Единственный шаг, который нельзя автоматизировать

`--hook-pick`. Акустика измеряет подачу, но не смысл: рекламная строка звучит
громко и на широком тоне, обещанием ролика не являясь. Скрипт честно ранжирует и
честно говорит, что это ранжирование. Посмотри shortlist в `cold_open.json` и
выбери фразу, которая ставит вопрос.
