# Движок субтитров: как он устроен и почему именно так

Всё, что здесь написано, проверено рендером и измерением, а не взято из документации.
Формулы и пороги подтверждены сравнением предсказания с настоящими пикселями
(`scripts/font_audit.py --measure-check`).

## Пять фактов про libass, без которых субтитры ломаются молча

### 1. `fontsdir` не рекурсивный

libass читает только файлы, лежащие **прямо** в указанной папке. Пак вида
`assets/fonts/<семейство>/<файл>.ttf` не подключается вообще, ошибки не возникает,
и рендер уходит на системный шрифт.

Поэтому `scripts/install_fonts.py` кроме папок по семействам собирает плоский пак
`assets/fonts/all/`, и именно его нужно передавать в `--fonts-dir`.
`scripts/burn_ass_captions.py` отказывается стартовать, если в переданной папке нет
шрифтов на первом уровне.

### 2. libass ищет семейство по `name ID 1`, а не по `name ID 16`

`Montserrat[wght].ttf` из Google Fonts сообщает `name ID 1 = "Montserrat Thin"`.
Стиль ASS с `Fontname: Montserrat` не находит ничего и падает на Arial.

Поэтому пак не отдаёт вариативные файлы в прожиг: `install_fonts.py` инстанцирует
статические начертания с корректными RIBBI-именами
(`Montserrat`/`Regular`, `Montserrat`/`Bold`, `Montserrat Black`/`Regular`).
`scripts/font_audit.py` затем прогоняет реальный рендер и читает решения libass из
её собственного лога `fontselect:`, поэтому подстановка системного шрифта — это
падение проверки, а не сюрприз в экспорте.

### 3. `Fontsize` в ASS — это не размер em

libass вызывает `FT_Request_Size` с `FT_SIZE_REQUEST_TYPE_REAL_DIM` и высотой
`size * (hhea.Ascender − hhea.Descender) / (usWinAscent + usWinDescent)`.
REAL_DIM отображает промежуток ascender→descender из `hhea` на эту высоту, слагаемые
`hhea` сокращаются, и em получается таким:

```
em = Fontsize × unitsPerEm / (usWinAscent + usWinDescent)
```

Для Montserrat это **0.64**. Наивное `em = Fontsize` завышает ширину строки больше
чем на 50%, и именно поэтому лимиты «не больше 32 символов» ведут себя по-разному от
шрифта к шрифту.

Межстрочное расстояние выходит `Fontsize × hheaSpan / winSpan`.

Проверка на паке: средняя ошибка предсказанной ширины против настоящих чернил
**0.64%**, максимум 1.6% (`--measure-check`). Порог приёмки: от −2% до +4%
(положительная ошибка ожидаема — чернила уже полуширин по краям).

### 4. `\p`-фигура не центрируется тегом `\an5`

libass смещает рисунок на половину его собственного габарита. Фигура, симметричная
относительно нуля, уезжает влево и вверх на половину своего размера. Фигура с
координатами от `0` до `w` центрируется ровно как текст.

Поэтому плашка за активным словом рисуется от `0 0` до `w h`, а скруглённые углы
строятся кубическими безье из той же системы координат
(`caption_engine.rounded_rect`). Прямой угол — самый заметный признак плашки,
нарисованной скриптом, а не дизайнером.

### 5. `\1c` несёт только цвет: прозрачность им не задаётся

Значение вида `&HAABBGGRR` выглядит так, будто первый байт — альфа. Для тега
`\1c` это не так: libass берёт из него только BGR, а альфу оставляет ту, что
пришла из стиля. Пресет с `"opacity": 0.72` рисовал полностью непрозрачную
плашку, а пресет с `"opacity": 0.0` — непрозрачную заливку вместо ничего.

Прозрачность задаётся отдельными тегами: `\1a` для заливки, `\3a` для обводки,
`\4a` для тени. Ошибка ничего не ломает громко — она просто рисует не то, что
написано в пресете, и это одинаково касалось плашки, подсветки слова, свечения и
хроматической каймы.

## Разбиение на блоки: две стадии, а не счётчик слов

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

```
распознавать изображения, но     ← «но» повисло
строю собственный движок по      ← «по» повисло
то есть разница почти            ← «почти» повисло
```

Эти слова **цепляются вперёд**: они относятся к тому, что идёт после, и без
продолжения читатель держит петлю, к которой ничего не привязано. Список таких
слов — закрытые классы русского языка (предлоги, союзы, частицы, местоименные
определители, числительные перед существительным), поэтому он полный, а не
выборочный: `caption_engine.PROCLITIC`.

Как устроено сейчас:

1. **Поток режется только там, где речь действительно останавливается** — конец
   предложения и измеренная пауза. Эти границы бесспорны.
2. **Каждая фраза сегментируется целиком, динамическим программированием.**
   Перебираются все допустимые точки разрыва, у каждого варианта считается цена.

Цена блока складывается в порядке того, насколько это мешает зрителю:

| Что | Штраф |
|---|---|
| строка не влезает в `max_chars` | 60 + 9 за символ |
| превышение `max_words` | 45 за слово |
| **блок кончается на цепляющемся слове** | 55 |
| блок длиннее `max_duration` | 40 за секунду |
| блок висит меньше `min_display` | 26 за секунду |
| отклонение от целевой длины | 3 за слово |
| блок кончается на запятой / точке | −14 / −20 (поощрение) |

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

Результат на реальном транскрипте (221 слово, пресет `signature_plate`): 78
блоков, **ноль** блоков, кончающихся на цепляющемся слове, максимум 30 символов
при лимите 30, максимум 4 слова при лимите 4.

## Что решает движок, а что libass

| Решение | Кто | Почему |
|---|---|---|
| Где разбить строку | движок | Считает по настоящим advance-ширинам глифов |
| Какой кегль | движок | Уменьшает только если перенос уже не помогает |
| Позиция строки | движок | `\an5\pos()` от безопасной зоны канваса |
| Центрирование внутри строки | libass | Ошибка измерения не сдвигает текст |
| Форма и кернинг глифов | libass | HarfBuzz шейпит лучше любой самоделки |

## Регистр применяется до измерения

Заглавная кириллица шире строчной. Если измерять «мы», а рисовать «МЫ», пословные
позиции налезают друг на друга. `apply_case()` вызывается перед группировкой и
раскладкой, поэтому и лимит символов, и ширины считаются по тому, что реально
попадёт в кадр.

## Зазор между словами учитывает обводку

В пословных режимах слова ставятся по измеренным координатам. Обводка расширяет
каждый глиф на `bord` пикселей в каждую сторону, поэтому зазора в одну ширину
пробела недостаточно — обводки соседних слов смыкаются и строка читается как одно
длинное слово. Дополнительный зазор: `2 × outline_ratio`, плюс `2 × pad_ratio` для
режима с плашкой.

## Режимы подсветки слова

| Режим | Как работает | Когда |
|---|---|---|
| `karaoke` | Тег `\kf`, накопительная заливка | Дёшево, спокойно, длинные ролики |
| `word_pop` | Отдельные события на слово, подсветка только на своём интервале | Максимальная динамика |
| `box` | То же плюс плашка `\p1` за активным словом | Ударные фразы, 1–2 на ролик |
| `one_word` | Одно слово в кадре | Очень быстрый ритм, короткая речь |
| `plain` | Без подсветки | Кино-подача, премиум, длинный текст |

В режиме `karaoke` цвета работают наоборот, чем кажется: непроизнесённая часть берёт
`SecondaryColour`, произнесённая — `PrimaryColour`. Движок кладёт базовый цвет в
`\2c`, а цвет заливки в `\1c`.

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

## Позиция и безопасная зона

Геометрия берётся из канваса (`scripts/skill_config.py`), а не из абсолютных
пикселей, поэтому один пресет работает и на 720×1280, и на 1080×1920.
`safe_bottom` для вертикали — 20% кадра: там подпись, кнопки и имя автора.

Проверять глазами:

```bash
python scripts/preview_caption_styles.py --out-dir caption_previews --canvas reels \
  --background frame.png --text "реальная фраза из транскрипта"
```

Контактный лист рисует зелёную рамку безопасной зоны. Пресет, который выглядит
хорошо сам по себе, всё равно может засунуть вторую строку под интерфейс TikTok.

## Отчёт вместо тишины

`caption_map.json` содержит для каждого блока: кегль после подгонки, измеренную
ширину, лимит, флаги `shrunk` и `overflow`, координаты каждого слова. Блок, который
не влез даже на минимальном кегле, помечается `overflow` и попадает в stderr —
молчаливое переполнение это то, что выносит текст за кадр.
