---
name: reels-talking-head-montage
description: Монтаж вертикального ролика «человек говорит в камеру + анимированная графика» (Reels, Shorts, TikTok) на своём компьютере, бесплатно. Расшифровка речи по словам, графика кодом в Remotion, привязанная к словам, три раскладки кадра, субтитры целыми фразами, обработка голоса, звуки на смысл, сборка готового mp4. Триггеры: «смонтируй рилс», «смонтируй видео, где я говорю в камеру», «сделай монтаж с инфографикой», «смонтируй шортс», «добавь графику к моему видео», «вот видео и текст, смонтируй».
---

# Монтаж рилса «говорю в камеру + графика»

> **Как поставить.** Скилл работает в Claude Code (в терминале или во вкладке Code приложения Claude): ему нужно запускать
> программы на вашем компьютере. Создайте папку `~/.claude/skills/reels-talking-head-montage/` и положите в неё этот файл
> под именем `SKILL.md`. Потом напишите Claude: «смонтируй рилс из видео ПУТЬ_К_ФАЙЛУ, вот текст, который я читал: …».
> В обычном чате на сайте монтаж не заработает: там нет доступа к вашим файлам и программам.

Ты монтируешь вертикальный ролик до 90 секунд: человек говорит в камеру, а поверх его речи появляется нарисованная кодом графика.
Каждая фраза получает свой предмет (карточка, схема, цифра, окно программы, цитата), каждое событие внутри графики привязано
к конкретному слову. Результат: готовый mp4 1080×1920, который можно сразу выкладывать.

Всё работает локально и бесплатно: ffmpeg, Node.js, Remotion, Whisper, Python. Платные сервисы не нужны.

Пользователь присылает:
- видеофайл (вертикальный, лучше 4K, 30 кадров/с; подойдёт и обычное видео с телефона);
- по желанию текст, который он читал (сценарий). С ним расшифровка точнее и проще писать слова на экране.

Больше ничего не спрашивай: палитру, сцены, раскладки и звуки выбираешь сам по правилам ниже. Вопрос задавай только если без
ответа нельзя работать (нет файла, файл не открывается).

Файлы проекта лежат в разделе 14 блоками кода; над каждым блоком стоит скрытая пометка с путём файла (HTML-комментарий
`file: путь`). При первом запуске создай из них проект
(раздел 3), дальше для каждого ролика меняются только `work/edit.json`, `src/reel/scenes.tsx` и `work/sfx.json`.

---

## 1. Железные правила

1. **Скорость речи не менять. Слова не обрезать.** Резать можно только тишину между словами и только по границам кадров
   (раздел 6, шаг 4). Звук и видео режутся по одним и тем же кадрам, иначе к концу ролика губы разъедутся со звуком.
2. **Графика привязана к словам, не к секундам.** В коде сцен события ставятся через `at(i)`: кадр начала i-го слова речи.
   Номера слов печатает `python3 tools/gen_data.py --list`. Секунды «на глаз» не использовать никогда.
3. **Первый кадр: человек на весь экран** (раскладка «человек + карточка»). Это обложка ролика в ленте.
4. **Раскладка не мелькает.** Блок одной раскладки длится не меньше 3,5 секунды. Внутри блока сцены могут меняться.
5. **Субтитр идёт целой фразой или не идёт вовсе.** Если слова фразы уже написаны в графике, субтитр этой фразы скрыт.
   Кусок субтитра не короче двух слов и не кончается предлогом.
6. **На экране только слова человека.** Что он произнёс, то и написано (орфография из сценария, если он есть). Свои слова,
   выдуманные цифры, пропорции, факты, логотипы и пункты меню не добавлять. Полоса «в 8 раз меньше» рисуется, только если
   он сказал «в 8 раз». Сказал «гораздо меньше» = только текст.
7. **Одна палитра на ролик, один акцентный цвет.** Красный и зелёный только как сигналы «плохо» и «хорошо». Палитры
   чередуются от ролика к ролику (раздел 8).
8. **Текст не вылезает за край.** Montserrat 800: строчная кириллица занимает примерно 0,66 кегля на знак. Строка на сцене
   не шире 936 px. В раскладке «графика сверху» сцена уменьшена до 0,72, поэтому кегль там не меньше 46 px.
9. **Карточка не пустует.** Первая секунда каждой сцены уже что-то показывает. Если первые слова сцены вводные, предметы
   стоят приглушёнными (прозрачность 0,3) и загораются на своём слове.
10. **Звуки только на смысл.** Три коротких удара на 85 секунд, разные по тембру, на главных словах. Никаких звуков на смену
    сцены, свистов и нарастаний под речью, повторяющихся щелчков.
11. **Громкость −14 LUFS**, истинный пик не выше −1 dBFS. Динамический `loudnorm` не использовать: мерить, поднимать усилением,
    пики срезать лимитером.
12. **Проверка до сдачи.** Кадры-проверки каждой сцены до рендера, после первого рендера просмотр всего ролика по листам кадров,
    правки, второй рендер. Отдаёшь только проверенный файл.
13. **Готовый ролик отдаёшь файлом**, а не только путём к нему. В отчёте таблица «в тексте / сказано», если был сценарий.

---

## 2. Как выглядит результат

Кадр 1080×1920, 30 кадров/с. Графика рисуется на «сцене» 1080×1000 (полезная зона x 72–1008, y 140–880), раскладка ставит и
масштабирует сцену. Три раскладки, переключаются жёстким стыком между сценами:

| Раскладка | Код | Как выглядит | Когда |
|---|---|---|---|
| Человек + карточка | `over` | Человек на весь экран (кадр увеличен на 6 % и поднят на 115 px, чтобы видео доходило до низа экрана), под подбородком тёмная карточка 700×420 (x 190, y 1050–1470), сцена вписана в неё по рамке `BOX`. Субтитр на плашке цвета карточки над ней (y ≈ 985, кегль 44). | Первый кадр и хук, смысловые повороты, финал и призыв |
| Графика сверху | `split` | Тёмная панель сверху y 0–770, сцена в масштабе 0,72; человек снизу в окне (видно его с y 390 кадра). Субтитр без подложки внутри панели (y ≈ 708, кегль 50). | Основной режим для объяснений |
| Графика на весь экран | `full` | Тёмный фон со звёздами, сцена в масштабе 1 с y 330, человека нет. Субтитр без подложки (y ≈ 1360, кегль 54). | Схемы, списки, разборы. Не дольше 8–11 секунд подряд |

Ориентир на 85 секунд: 12–20 сцен, 40–45 фраз, субтитры у 10–15 фраз (остальное написано в графике), 9–11 смен раскладки.
Порядок блоков чередуется: over → split → full → over → split → full… Два одинаковых блока подряд сливаются в один.

Безопасная зона Instagram: сверху до y 250 и снизу от y 1470 интерфейс, справа от x 950 кнопки. Главный смысл держать в
y 250–1470 и x 64–930. Панель «графика сверху» частично заходит выше 250: туда ставить второстепенное (шапку шага, номер).

---

## 3. Установка (один раз)

Проверь, что есть: `ffmpeg -version`, `node -v` (нужна 18 или новее), `python3 --version`. Если чего-то нет, поставь:

- macOS: `brew install ffmpeg node python`
- Windows: `winget install Gyan.FFmpeg OpenJS.NodeJS.LTS Python.Python.3.12` (потом перезапустить терминал)
- Linux: `sudo apt install ffmpeg nodejs npm python3 python3-pip`

Расшифровка речи по словам:
- Mac на Apple Silicon (M1–M4): `pip3 install mlx-whisper` (быстро, на видеокарте Mac).
- Windows, Linux, Mac на Intel: `pip3 install faster-whisper` (скрипт `tools/transcribe.py` ниже).
- Для листов кадров: `pip3 install pillow`.

Создай папку проекта (например `~/reel`), в ней файлы из всех блоков раздела 14 (путь каждого файла в скрытой пометке над блоком), затем:

```bash
cd ~/reel
npm install
python3 tools/make_hits.py
```

`make_hits.py` один раз синтезирует три коротких удара в `tools/hits/` (свои, без чужих звуков и лицензий).

Заметки по установке:
- При первом рендере Remotion сам скачает Chrome Headless Shell (около 80 МБ). Это нормально.
- Свежий npm может написать `allow-scripts … esbuild`: на работу не влияет, сборка проходит.
- Если ffmpeg стоит не в PATH, укажи путь переменными: `FFMPEG=/путь/ffmpeg FFPROBE=/путь/ffprobe python3 tools/gen_data.py`.
- На Windows вместо `python3` пиши `python`.
- Образец `scenes.tsx` из раздела 14 рабочий: после `gen_data.py` его можно сразу отрендерить и посмотреть, как выглядит стиль,
  а потом переписать сцены под свой текст.

Remotion бесплатен для частных лиц и компаний до трёх человек; крупным компаниям нужна лицензия (условия на remotion.dev/license).
Шрифт Montserrat (свободная лицензия OFL) подгружается из Google Fonts пакетом `@remotion/google-fonts`, поэтому при рендере
нужен интернет.

Структура проекта:

```
reel/
  package.json, tsconfig.json
  src/index.ts, src/Root.tsx
  src/reel/kit.tsx        общие помощники, палитры, значки (не меняется)
  src/reel/Layout.tsx     три раскладки, субтитры, сцена (не меняется)
  src/reel/scenes.tsx     сцены ЭТОГО ролика (пишется заново под каждый текст)
  src/reel/data.json      кадры слов и субтитры (создаёт gen_data.py)
  public/base.mp4         подготовленное видео (создаёт gen_data.py)
  tools/*.py, tools/stills.mjs
  work/edit.json          настройки ролика: файл, правки расшифровки, фразы, субтитры
  work/sfx.json           удары
  out/                    готовые файлы и проверки
```

---

## 4. Порядок работы (каждый ролик)

1. **Принять файл.** `ffprobe` покажет длительность, разрешение, частоту кадров, поворот. Видео дольше 90 секунд для Reels
   длинновато: скажи об этом, но монтируй.
2. **Найти паузы:** `ffmpeg -i ВИДЕО -vn -af silencedetect=noise=-40dB:d=0.3 -f null -` (паузы в выводе). Паузы до 0,5 с не трогать.
   Если есть паузы длиннее 0,6 с, в `edit.json` поставить `"cut_pauses_over": 0.6` (раздел 6, шаг 4).
3. **Снять кадр** (`ffmpeg -ss 10 -i ВИДЕО -frames:v 1 -vf scale=540:-2 work/frame.jpg`) и посмотреть: где макушка и подбородок,
   какая одежда, светлый или тёмный фон. Это нужно для раскладок (раздел 7, `SPEAKER`).
4. **Расшифровать** (раздел 5): основной прогон и прогон для сверки.
5. **Заполнить `work/edit.json`:** путь к видео, правки слов (`fix`, `merge`, `drop`), затем запустить
   `python3 tools/gen_data.py --list`. Скрипт соберёт основу видео и голос, напечатает слова с номерами и кадрами и фразы.
6. **Разбить текст на фразы** (`ends`): номер последнего слова каждой фразы. Фраза = законченная мысль на 2–9 слов.
   Если `ends` пустой, скрипт режет по знакам препинания; это черновик, поправь руками.
7. **Придумать сцены** (раздел 9): разбить фразы на 12–20 сцен, каждой дать раскладку и предмет. Записать план себе
   (сцена → номера слов → раскладка → что на экране → какие слова в графике).
8. **Выбрать палитру** под тему (раздел 8). Записать в `scenes.tsx` (`PALETTE`).
9. **Отметить субтитры** (`show` в `edit.json`): номера фраз, которые графика показывает БЕЗ слов. Остальные фразы скрыты.
   Перезапустить `gen_data.py`.
10. **Написать `src/reel/scenes.tsx`** под этот текст (раздел 9).
11. **Кадры-проверки** (раздел 11): по 2–4 кадра на сцену, лист кадров, смотреть глазами. Править, пока не чисто.
12. **Звук:** `work/sfx.json` (три удара на смысл), `python3 tools/build_audio.py`.
13. **Рендер и сборка:** `python3 tools/render.py`. Скрипт отрендерит, сведёт со звуком, проверит файл и сделает листы кадров.
14. **Просмотр всего ролика** по листам `out/check/list_*.jpg`. Нашёл огрех: поправить, снова `render.py`.
15. **Сдать:** отправить файл пользователю, коротко описать (палитра и почему, сколько сцен, где удары) и, если был сценарий,
    дать таблицу расхождений «в тексте / сказано».

Время на ролик 85 секунд на Mac M-серии: основа 3–5 минут, рендер 6–10 минут, вся работа с проверками 40–70 минут.

---

## 5. Расшифровка: точная, по словам, с проверкой

Формат, который ждёт `gen_data.py`: JSON `{"segments": [{"words": [{"word": "...", "start": 1.23, "end": 1.56}, ...]}, ...]}`.
Его выдают и mlx-whisper, и `tools/transcribe.py`.

**Mac (Apple Silicon):**

```bash
ffmpeg -y -i ВИДЕО -vn -ar 16000 -ac 1 work/a16.wav
mlx_whisper work/a16.wav --model mlx-community/whisper-large-v3-mlx --language ru --word-timestamps True \
  --condition-on-previous-text False --initial-prompt "НАЧАЛО СЦЕНАРИЯ И ВСЕ ИМЕНА, НАЗВАНИЯ, ТЕРМИНЫ" \
  --output-format json --output-dir work/run1
mlx_whisper work/a16.wav --model mlx-community/whisper-large-v3-turbo --language ru --word-timestamps True \
  --output-format json --output-dir work/run2
```

**Windows, Linux, Intel Mac:**

```bash
python3 tools/transcribe.py work/a16.wav work/run1/a16.json large-v3 "НАЧАЛО СЦЕНАРИЯ И ИМЕНА"
python3 tools/transcribe.py work/a16.wav work/run2/a16.json medium
```

Основной прогон: large-v3 с подсказкой (`initial-prompt`): начало сценария, все имена, названия программ и термины.
Прогон для сверки: другая модель без подсказки. Что оба прогона слышат одинаково, то и сказано.

Что проверять в расшифровке:
- **Зацикливание.** Whisper иногда выдаёт два десятка выдуманных слов с одним и тем же временем начала или приписывает в конце
  «Субтитры создавал…», «Продолжение следует…». `gen_data.py` предупреждает об этом. Лечение: прогон с подсказкой и
  `--condition-on-previous-text False`, за основу взять прогон без зацикливания.
- **Лишние слова.** Призвук или вдох превращается в «к», «а», «сып»: убрать через `drop` (номер слова в сырой расшифровке).
- **Слова, разбитые на два.** «чат GPT», «И Лиза» (= «Элиза»), «не правы»: склеить через `merge` (номер второй части), а текст
  задать в `fix` первой части.
- **Написание.** Имена, бренды (Claude, ChatGPT), буква ё, «неправы» слитно, числа словами или цифрами: через `fix`.
- **Начало слова после паузы.** Whisper растягивает первое слово фразы на тишину перед ним, и графика выскакивает раньше звука.
  `gen_data.py` сам переносит начало такого слова на конец тишины (из `silencedetect`).
- **Сверка со сценарием.** Если пользователь прислал текст: на экране то, что ПРОИЗНЕСЕНО (зритель слышит речь), орфография из
  сценария. Все расхождения записать для отчёта таблицей «в тексте / сказано». Мелкие слова («от», «и», «же») Whisper может
  дорисовать: если прогоны расходятся, верить большинству и отметить в отчёте.

---

## 6. Подготовка данных: `gen_data.py` и `edit.json`

`work/edit.json` (пример):

```json
{
  "source": "/Users/me/Downloads/IMG_1234.mov",
  "transcript": "work/run1/a16.json",
  "fix": {"4": "Claude", "29": "инструкции.", "66": "неправы."},
  "merge": [67, 96],
  "drop": [165],
  "ends": [8, 16, 19, 26],
  "show": [1, 3, 5],
  "whole": [],
  "cut_pauses_over": 0,
  "grade": true,
  "out_name": "reel_final.mp4"
}
```

- `fix`: номер слова в СЫРОЙ расшифровке → как писать.
- `merge`: номера слов сырой расшифровки, которые приклеить к предыдущему слову (их время конца переходит предыдущему).
- `drop`: номера слов сырой расшифровки, которые выбросить.
- `ends`: номер последнего слова каждой фразы в ИТОГОВОМ списке (его печатает `--list`).
- `show`: номера фраз, у которых показывать субтитр.
- `whole`: номера фраз, которые показывать одной строкой, не деля (если деление выходит плохим: кусок кончается предлогом).
- `cut_pauses_over`: 0 = не резать; 0.6 = вырезать середину пауз длиннее 0,6 с (по 0,12 с тишины остаётся с каждой стороны).
- `grade`: лёгкий цветокор (чуть светлее середина, насыщенность +4 %, мягкая виньетка).

Что делает скрипт:
1. Находит тишину в исходнике, читает расшифровку, применяет `fix`, `merge`, `drop`, поправляет начала слов после пауз.
2. Считает шкалу времени: старт за 0,09 с до первого слова, конец через 0,2 с после последнего. При `cut_pauses_over`
   выкидывает середины длинных пауз.
3. Один проход ffmpeg из исходника: 30 кадров/с, вырезка по кадрам, 1152×2048 (запас под увеличение на 6 %), цветокор →
   `public/base.mp4`, и ровно по тем же кадрам голос → `work/voice.wav`. Проверяет, что число кадров и длина звука совпали.
4. Переводит время каждого слова в кадр новой шкалы → `src/reel/data.json` (`ws` начала, `we` концы, `wt` слова).
5. Собирает фразы, делит их на куски субтитров (не длиннее 24 знаков, не короче 2 слов, без предлога в конце куска),
   скрытые фразы помечает `hide`.

Основа пересобирается сама, если поменялись вырезанные куски или файла ещё нет; `--base` пересобирает принудительно.

---

## 7. Раскладка под конкретную съёмку: `SPEAKER`

В `scenes.tsx` три числа подстраиваются под кадр:

```ts
export const SPEAKER = { splitY0: 390, overShift: 115, overScale: 1.06 };
```

- `splitY0`: с какой высоты кадра (в пикселях кадра 1080×1920) показывать человека в нижнем окне раскладки `split`.
  Макушка должна оказаться на 60–110 px ниже края панели. Голова выше в кадре = число меньше.
- `overShift` и `overScale`: в раскладке `over` кадр увеличен и поднят, чтобы подбородок был над карточкой, а видео доходило
  до низа экрана. Условие: `1920 × overScale − overShift ≥ 1920`. Пустая полоса внизу экрана = брак.

Проверка: кадр-проверка каждой раскладки (раздел 11). Субтитр-плашка в `over` стоит на уровне шеи, выше карточки.

---

## 8. Палитры: чередовать под тему

Шесть тёмных благородных палитр. Структура, шрифт и раскладки одни и те же, меняется только цвет. Выбирай по ЧУВСТВУ истории,
не по словам: ролик про нейросети, но о доверии и личном = слива, а не графит. Одну палитру два ролика подряд не ставить.

| Ключ | Палитра | Фон / карточка | Акцент | Темы |
|---|---|---|---|---|
| `coffee` | Кофе и терракота | #170e0a / #2a1b15 | #e0805e | истории о людях, медицина, универсальная |
| `ink` | Чернила и золото | #0a101c / #172236 | #d2ad66 | власть, деньги, империи, большие имена |
| `emerald` | Изумруд и латунь | #08140f / #14271e | #d1b06c | игры, стратегия, механизмы, изобретения |
| `bordeaux` | Бордо и шампань | #16080c / #2c131b | #e3c398 | драма, преступления, скандалы |
| `graphite` | Графит и лёд | #0d1013 / #1b2127 | #a3c8df | технологии, программы, инструкции |
| `plum` | Слива и пыльная роза | #130c17 / #261a2f | #dda7ae | психология, отношения, доверие |

Правила цвета: один акцент на ролик; текст тёплый светлый, не чисто белый; красный (#f2503e) и зелёный (#42c98a) только сигналы;
ярких чистых цветов и светлых фонов нет. Плашка субтитра поверх видео берёт цвет карточки текущей палитры (градиент `hi → card`,
рамка, тень), а не чёрный: иначе плашки разного цвета и это заметно. На панели и полноэкранном фоне подложки у субтитров нет.

---

## 9. Как писать сцены

Сцена = React-компонент, который рисует предметы на сцене 1080×1000. Все события через номера слов:

```tsx
const pill = usePop(at(41) - 2);          // плашка выпрыгивает за 2 кадра до слова 41
const t = useTyped([[25, "Вейценбаум"]]);  // имя печатается ровно пока звучит слово 25
const w = useWords(0, 8, [4, 6]);          // слова 0–8 речи печатаются, перенос строки после слов 4 и 6
const a = up(f, at(20), 8);                // плавное появление за 8 кадров с начала слова 20
```

**Как придумывать.** Для каждой фразы спроси себя: какой ПРЕДМЕТ это показывает? Не подпись к речи, а вещь или действие:
окно программы и курсор, папка и файлы, весы, календарь, плитка «до / после», переписка, лист с правилами. Слова на экране
только ключевые, и только из речи.

**Набор приёмов** (в `kit.tsx` есть помощники для всех):
- заголовок печатается по словам + волнистая черта под главным словом;
- две плитки «✓ / ✕» (кто прав, что работает);
- крупная цифра на своём слове + подпись + ряд значков (цифра встаёт сразу, без прокрутки счётчика);
- облачко реплики с хвостиком: три точки, пока человек не начал цитату, потом цитата печатается;
- окно программы: шапка с тремя точками, боковая панель полосками, поле ввода; курсор ходит по ключевым кадрам и «нажимает»
  на словах действия («нажимаете», «выбираете»), меню появляется через 3–4 кадра после нажатия;
- шапка шага: номер в круге, название и точки хода «1 из 4»;
- переключатели, которые включаются по одному на словах;
- поле со списком, пункты печатаются; ниже красные плашки «нельзя: …»;
- стрелки «запрос → ответ» между двумя плитками, по стрелке едет документ;
- полоса, которая растёт или сжимается в столько раз, сколько назвал человек;
- сетка клеток (месяцы, дни), клетки закрашиваются по одной;
- значок с красным крестиком на слове «нельзя», с зелёной галочкой на «работает»;
- плитка, уезжающая из кадра («выйти из комнаты»);
- финал: мораль печатается 2–3 строками, главное слово акцентным цветом, волнистая черта.

**Тайминг.** Новая сцена начинается со слова, с которого начинается её предмет. Если первые слова сцены вводные
(«Умела она, честно говоря, …»), оставь предыдущую сцену до первого слова, которое рисуется: `from: at(36) - 3`.
Раскладки должны совпадать, иначе блок станет короче 3,5 с.

**Размеры.** Кегли на сцене: заголовок 80–110 px, строки 52–66, подписи 40–50, плашки 38–50. В `split` всё уменьшится до 0,72,
в `over` примерно до 0,62–0,69 (зависит от `BOX`). Ширина строки: знаков × кегль × 0,66 ≤ 936.

**Рамка `BOX`.** Для каждой сцены `[x0, y0, x1, y1]`: где реально лежит содержимое на сцене 1080×1000. По ней сцена вписывается в
карточку раскладки `over`. Рамка шире содержимого = мелко и пусто; уже = обрежется.

**Свечение.** У каждой сцены `gx, gy` (проценты): где тёплое пятно света. Оно переезжает к новой сцене за 18 кадров.

---

## 10. Звук

`tools/build_audio.py` делает одну и ту же цепочку для любого голоса:

срез ниже 80 Гц → лёгкое шумоподавление (6 дБ) → −2,5 дБ на 300 Гц (убирает «бубнёж») → +3 дБ на 3,5 кГц (разборчивость) →
полка +3 дБ от 8 кГц (воздух) → де-эссер → компрессор 3:1 (порог −24 дБ, атака 10 мс, отпуск 120 мс) → лимитер −1,5 дБ.
Гейт не ставить: он съедает окончания слов.

Громкость: дорожка моно идёт в оба канала (это +3 LU), поэтому моно доводится до −17 LUFS, в готовом стерео выходит −14 LUFS.
Скрипт меряет, поднимает усилением, повторяет до точности 0,15 LU, пики срезает лимитер. Отчёт: итоговая громкость, истинный пик,
разброс кратковременной громкости (норма до 5 LU).

Удары: `work/sfx.json`, три штуки, на слова с главным смыслом (цифра, ударное слово, кодовое слово в финале):

```json
[{"word": 102, "hit": "a"}, {"word": 180, "hit": "b", "shift": -2}, {"word": 210, "hit": "c"}]
```

`word` = номер слова (удар в его первый кадр), `shift` = сдвиг в кадрах. Пик ударов −8 dBFS, длина 0,2–0,4 с.

---

## 11. Проверки

**Кадры-проверки до рендера.** По 2–4 кадра на сцену: первый кадр сцены, середина, момент после последнего события.

```bash
node tools/stills.mjs Reel out/st 0,60,150,300,420
python3 tools/sheet.py out/st out/st/sheet 6
```

`sheet.py` кладёт кадры в ряд и рисует линии безопасной зоны (y 250, y 1470, x 950). Смотреть каждый лист глазами:
- текст не вылезает за карточку и край экрана, строки не обрезаны;
- карточка и панель не пустуют дольше секунды; в начале сцены уже есть предметы;
- субтитр не дублирует слова, написанные в графике, и не наезжает на карточку;
- видео в раскладке `over` доходит до низа экрана, лицо не перекрыто;
- значки и подписи не наезжают друг на друга;
- главное в безопасной зоне.

**После первого рендера** `render.py` сам делает листы всего ролика (кадр каждые 1,5 с) в `out/check/`. Просмотреть все листы.
Огрехи, которых не видно на отдельных кадрах (пустая панель секунду, сцена сменилась раньше слова), видны только так.
Поправить, отрендерить снова, ещё раз просмотреть.

**Файл.** `render.py` проверяет: декодируется без ошибок, число кадров, громкость около −14 LUFS, пик не выше −1 dBFS.

---

## 12. Грабли и решения

| Что случилось | Причина | Что делать |
|---|---|---|
| Рендер падает «disk space is low» / «Failed to fetch» | на диске меньше 5–6 ГБ; каждый рендер оставляет временную сборку | освободить место; `render.py` удаляет временные `remotion-webpack-bundle-*`; уменьшить `CONCURRENCY` |
| Рендер очень долгий в начале | в `public/` лежат лишние большие файлы, всё копируется в сборку | в `public/` только `base.mp4` |
| Звук к концу отстаёт от губ | видео и звук резали разными способами | только `gen_data.py`: оба режутся по одним кадрам, скрипт сверяет длины |
| Графика выскакивает раньше слова | Whisper растянул слово на паузу перед ним | уже исправляется по `silencedetect`; если осталось, `shift` на 2–4 кадра в сцене |
| Внизу экрана тёмная полоса в `over` | человека подняли без увеличения | условие `1920 × overScale − overShift ≥ 1920` |
| Цвета в Instagram тусклее или ярче | нет меток цветового пространства | рендер `--color-space bt709` + `setparams` в финальном кодировании (уже в `render.py`) |
| В расшифровке выдуманные слова подряд | Whisper зациклился | раздел 5: прогон с подсказкой и без продолжения контекста, сверка |
| Субтитр по одному слову | фраза частично скрыта | субтитр только целой фразой: фраза или вся в `show`, или скрыта |
| Строка кончается предлогом «Сиденье под / шахматистом» | неудачное деление | номер фразы в `whole` или пересобрать фразы |
| Шрифт не кириллический / квадраты | не загрузился Montserrat (нет сети) | рендер с интернетом; или положить TTF в `public/fonts/` и подключить через `FontFace` |
| `npx` не найден на Windows | путь к Node не обновился | перезапустить терминал после установки Node |

---

## 13. Советы человеку на съёмку (сказать, если спросит, как снять лучше)

- 4K, 30 кадров/с, вертикально; заблокировать экспозицию и баланс белого; свет сбоку под 45°.
- Глаза на верхней трети кадра, над головой немного воздуха; одежда без крупного принта на уровне груди (там субтитры).
- Петличка или микрофон рядом: чистый звук важнее картинки.
- Первую секунду руки неподвижны (первый кадр = обложка); после последнего слова 2 секунды смотреть в камеру.
- Говорить без длинных пауз: резать их не придётся.

---

## 14. Файлы проекта

Создай каждый файл из блока ниже его пометки. Не меняй `kit.tsx` и `Layout.tsx` без причины: в них правила стиля.
Под каждый ролик меняются `work/edit.json`, `src/reel/scenes.tsx`, `work/sfx.json`.

<!-- file: package.json -->
```json
{
  "name": "reel",
  "private": true,
  "scripts": {
    "studio": "remotion studio src/index.ts"
  },
  "dependencies": {
    "@remotion/bundler": "4.0.245",
    "@remotion/cli": "4.0.245",
    "@remotion/google-fonts": "4.0.245",
    "@remotion/renderer": "4.0.245",
    "react": "18.3.1",
    "react-dom": "18.3.1",
    "remotion": "4.0.245"
  },
  "devDependencies": {
    "@types/react": "18.3.12",
    "typescript": "5.6.3"
  }
}
```

<!-- file: tsconfig.json -->
```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": false,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

<!-- file: src/index.ts -->
```ts
import { registerRoot } from "remotion";
import { Root } from "./Root";

registerRoot(Root);
```

<!-- file: src/Root.tsx -->
```tsx
import React from "react";
import { Composition } from "remotion";
import { Reel, REEL_FRAMES } from "./reel/Layout";

export const Root: React.FC = () => (
  <Composition id="Reel" component={Reel} durationInFrames={REEL_FRAMES} fps={30} width={1080} height={1920} />
);
```

<!-- file: src/reel/kit.tsx -->
```tsx
// Общие помощники стиля: шрифт, палитры, анимации, печать текста, значки, элементы интерфейса.
import React, { createContext, useContext } from "react";
import { Easing, interpolate, spring, useCurrentFrame } from "remotion";
import { loadFont } from "@remotion/google-fonts/Montserrat";
import data from "./data.json";

const { fontFamily } = loadFont("normal", { weights: ["500", "700", "800"], subsets: ["cyrillic", "latin"] });
export const SANS = `${fontFamily}, sans-serif`;

// ---------- данные речи: кадры слов ----------
export type Sub = { from: number; to: number; text: string; hide?: boolean };
export const D = data as { frames: number; ws: number[]; we: number[]; wt: string[]; subs: Sub[] };
export const at = (i: number) => D.ws[i]; // кадр начала слова i
export const en = (i: number) => D.we[i]; // кадр конца слова i

// ---------- палитры ----------
export type Theme = { title: string; bg: string; card: string; hi: string; hi2: string; deep: string; line: string; text: string; soft: string; accent: string; red: string; green: string; glow: string; shadow: string; flat?: boolean };
const base = { line: "rgba(255,240,225,0.12)", text: "#f8f3ea", soft: "rgba(248,243,234,0.62)", red: "#f2503e", green: "#42c98a", shadow: "0 26px 70px rgba(0,0,0,0.55)" };
export const PALETTES: Record<string, Theme> = {
  coffee: { ...base, title: "Кофе и терракота", bg: "#170e0a", card: "#2a1b15", hi: "#3a2218", hi2: "#33201a", deep: "#0d0806", accent: "#e0805e", glow: "214,120,84", red: "#ff3a2d", green: "#35d07f", text: "#fff6ee" },
  ink: { ...base, title: "Чернила и золото", bg: "#0a101c", card: "#172236", hi: "#1d2c48", hi2: "#18243a", deep: "#05080f", accent: "#d2ad66", glow: "190,160,110", line: "rgba(220,230,255,0.13)" },
  emerald: { ...base, title: "Изумруд и латунь", bg: "#08140f", card: "#14271e", hi: "#18382a", hi2: "#142e23", deep: "#040a07", accent: "#d1b06c", glow: "150,170,110", line: "rgba(220,255,235,0.13)" },
  bordeaux: { ...base, title: "Бордо и шампань", bg: "#16080c", card: "#2c131b", hi: "#421826", hi2: "#36151f", deep: "#0b0406", accent: "#e3c398", glow: "200,105,110", red: "#ff5a47" },
  graphite: { ...base, title: "Графит и лёд", bg: "#0d1013", card: "#1b2127", hi: "#252e37", hi2: "#1f262d", deep: "#07090b", accent: "#a3c8df", glow: "130,170,205", text: "#f2f5f7", soft: "rgba(242,245,247,0.62)", line: "rgba(225,240,255,0.12)" },
  plum: { ...base, title: "Слива и пыльная роза", bg: "#130c17", card: "#261a2f", hi: "#372341", hi2: "#2e1d38", deep: "#09060c", accent: "#dda7ae", glow: "185,125,170" },
};
const Ctx = createContext<Theme>(PALETTES.coffee);
export const ThemeProvider = Ctx.Provider;
export const useTheme = () => useContext(Ctx);

// ---------- анимации ----------
export const clamp = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
export const up = (f: number, a: number, dur = 8) => interpolate(f, [a, a + dur], [0, 1], { ...clamp, easing: Easing.out(Easing.cubic) });
export const rise = (o: number, dy = 18): React.CSSProperties => ({ opacity: o, transform: `translateY(${(1 - o) * dy}px)` });
export const springAt = (f: number, a: number) => (f < a ? 0 : spring({ frame: f - a, fps: 30, config: { damping: 11, stiffness: 190, mass: 0.6 } }));
export const usePop = (a: number) => springAt(useCurrentFrame(), a);
export const pop = (p: number, from = 0.7): React.CSSProperties => ({ opacity: Math.min(1, p * 1.6), transform: `scale(${from + (1 - from) * p})` });

// ---------- печать текста по словам: [номер слова, текст] ----------
export const useTyped = (parts: [number, string][]) => {
  const f = useCurrentFrame();
  let text = "";
  for (const [i, t] of parts) {
    const a = at(i), b = Math.max(a + 2, en(i));
    if (f >= b) text += t;
    else if (f >= a) text += t.slice(0, Math.max(1, Math.round((t.length * (f - a)) / (b - a))));
  }
  const last = parts[parts.length - 1];
  return { text, typing: f >= at(parts[0][0]) && f < en(last[0]) };
};
// слова i..j речи как есть (из data.json); breaks – после каких слов перенос строки; в конце точка/запятая убирается
export const useWords = (i: number, j: number, breaks: number[] = []) => {
  const parts: [number, string][] = [];
  for (let k = i; k <= j; k++) {
    let w = D.wt[k].replace(/[«»"]/g, "");
    if (k === j || breaks.includes(k)) w = w.replace(/[.,;:…]+$/, "");
    parts.push([k, (k === i ? "" : breaks.includes(k - 1) ? "\n" : " ") + w]);
  }
  return useTyped(parts);
};
export const Caret: React.FC<{ h: number }> = ({ h }) => {
  const f = useCurrentFrame();
  const T = useTheme();
  return <span style={{ display: "inline-block", width: Math.max(4, h * 0.07), height: h, marginLeft: 6, background: T.text, verticalAlign: "-0.12em", opacity: Math.floor(f / 8) % 2 ? 0.25 : 1 }} />;
};
// строки печатающегося текста, у каждой свой кегль и цвет
export const Lines: React.FC<{ text: string; typing: boolean; st: { size: number; color: string }[]; lh?: number }> = ({ text, typing, st, lh = 1.14 }) => {
  const lines = text.split("\n");
  return <>{st.map((s, i) => <div key={i} style={{ font: `800 ${s.size}px/${lh} ${SANS}`, color: s.color, whiteSpace: "nowrap" }}>{lines[i] || "\u00a0"}{typing && lines.length === i + 1 ? <Caret h={s.size * 0.84} /> : null}</div>)}</>;
};
export const Wavy: React.FC<{ w: number; p: number; color: string }> = ({ w, p, color }) => {
  const n = Math.ceil(w / 28);
  return (
    <svg width={w} height={22} viewBox={`0 0 ${w} 22`} style={{ overflow: "visible", display: "block" }}>
      <path d={`M2 11 q 7 -14 14 0 ${"t 14 0 ".repeat(n * 2)}`} fill="none" stroke={color} strokeWidth={7} strokeLinecap="round" strokeDasharray={w * 1.6} strokeDashoffset={w * 1.6 * (1 - p)} />
    </svg>
  );
};

// ---------- плашки, карточки ----------
export const Pill: React.FC<{ color: string; children: React.ReactNode; size?: number; solid?: boolean }> = ({ color, children, size = 44, solid }) => {
  const T = useTheme();
  return <div style={{ display: "inline-flex", alignItems: "center", gap: size * 0.35, padding: `${size * 0.36}px ${size * 0.7}px`, borderRadius: size * 1.2, border: `3px solid ${color}`, background: solid ? color : `${color}1c`, color: solid ? (color === T.accent ? T.bg : "#fff") : color, font: `800 ${size}px/1 ${SANS}`, whiteSpace: "nowrap", boxShadow: `0 0 ${size}px ${color}55` }}>{children}</div>;
};
export const cardStyle = (T: Theme): React.CSSProperties => ({ background: T.card, border: `2px solid ${T.line}`, borderRadius: 30, boxShadow: T.shadow, boxSizing: "border-box" });
export const tileStyle = (T: Theme, s = 250): React.CSSProperties => ({ position: "relative", width: s, height: s, ...cardStyle(T), borderRadius: 34, display: "flex", alignItems: "center", justifyContent: "center" });
export const Badge: React.FC<{ color: string; scale: number; children: React.ReactNode }> = ({ color, scale, children }) => <div style={{ position: "absolute", right: -26, top: -26, width: 96, height: 96, borderRadius: 48, background: color, display: "flex", alignItems: "center", justifyContent: "center", transform: `scale(${scale})` }}>{children}</div>;
// облачко реплики: прямоугольник (x0, y0)–(x1, y1) с хвостиком вверх у x = tx
export const Balloon: React.FC<{ x0: number; y0: number; x1: number; y1: number; tx: number; o: number }> = ({ x0, y0, x1, y1, tx, o }) => {
  const T = useTheme();
  return <svg width={1080} height={1000} style={{ position: "absolute", left: 0, top: 0, opacity: o }}><path d={`M${x0 + 40} ${y0} H${x1 - 40} a40 40 0 0 1 40 40 V${y1 - 40} a40 40 0 0 1 -40 40 H${x0 + 40} a40 40 0 0 1 -40 -40 V${y0 + 40} a40 40 0 0 1 40 -40 Z M${tx} ${y0 + 2} L${tx + 26} ${y0 - 42} L${tx + 66} ${y0 + 2}`} fill={`${T.accent}14`} stroke={T.accent} strokeWidth={4} strokeLinejoin="round" /></svg>;
};
export const Dots: React.FC<{ x: number; y: number; s?: number; o?: number }> = ({ x, y, s = 40, o = 1 }) => {
  const f = useCurrentFrame();
  const T = useTheme();
  return <div style={{ position: "absolute", left: x, top: y, display: "flex", gap: s * 0.65, opacity: o }}>{[0, 1, 2].map((i) => <div key={i} style={{ width: s, height: s, borderRadius: s / 2, background: T.accent, opacity: Math.floor(f / 7) % 3 === i ? 1 : 0.3 }} />)}</div>;
};

// ---------- значки (рисованные, без чужих логотипов) ----------
type Ic = { size: number; color: string };
export const Check: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 64 64"><path d="M12 34 L26 48 L52 18" fill="none" stroke={color} strokeWidth={9} strokeLinecap="round" strokeLinejoin="round" /></svg>;
export const Cross: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 46 46"><path d="M8 8 L38 38 M38 8 L8 38" stroke={color} strokeWidth={8} strokeLinecap="round" /></svg>;
export const Person: React.FC<Ic & { dashed?: boolean }> = ({ size, color, dashed }) => (
  <svg width={size} height={size} viewBox="0 0 100 100" fill={dashed ? "none" : color} stroke={color} strokeWidth={dashed ? 4 : 0} strokeDasharray={dashed ? "7 7" : undefined}><circle cx={50} cy={30} r={18} /><path d="M14 96 C16 68 34 56 50 56 C66 56 84 68 86 96 Z" /></svg>
);
export const Bubble: React.FC<Ic & { dots?: boolean }> = ({ size, color, dots = true }) => (
  <svg width={size} height={size * 0.846} viewBox="0 0 130 110" fill="none" stroke={color} strokeWidth={6} strokeLinejoin="round"><path d="M10 12 H120 V76 H52 L30 98 V76 H10 Z" />{dots ? [44, 65, 86].map((x) => <circle key={x} cx={x} cy={44} r={6} fill={color} stroke="none" />) : null}</svg>
);
export const Doc: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={6} strokeLinejoin="round" strokeLinecap="round"><path d="M22 8 H62 L80 26 V92 H22 Z M62 8 V26 H80" /><path d="M34 46 H68 M34 60 H68 M34 74 H56" /></svg>;
export const Folder: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={6} strokeLinejoin="round"><path d="M8 24 H40 L50 36 H92 V82 H8 Z" /><path d="M30 58 H70" strokeLinecap="round" /></svg>;
export const Eye: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={6} strokeLinejoin="round"><path d="M6 50 Q50 12 94 50 Q50 88 6 50 Z" /><circle cx={50} cy={50} r={13} fill={color} stroke="none" /></svg>;
export const Envelope: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={6} strokeLinejoin="round"><rect x={8} y={22} width={84} height={58} rx={8} /><path d="M10 26 L50 56 L90 26" /></svg>;
export const Heart: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100"><path d="M50 86 C10 58 8 30 28 20 C40 14 48 22 50 30 C52 22 60 14 72 20 C92 30 90 58 50 86 Z" fill={color} /></svg>;
export const Book: React.FC<Ic> = ({ size, color }) => <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={5} strokeLinejoin="round"><path d="M50 26 C38 16 20 16 8 22 V80 C20 74 38 74 50 84 C62 74 80 74 92 80 V22 C80 16 62 16 50 26 Z M50 26 V84" /></svg>;
export const Monitor: React.FC<Ic & { label?: string }> = ({ size, color, label }) => {
  const T = useTheme();
  return (
    <svg width={size} height={size} viewBox="0 0 100 100" fill="none" stroke={color} strokeWidth={5} strokeLinejoin="round" strokeLinecap="round">
      <rect x={8} y={12} width={84} height={58} rx={8} /><path d="M30 90 H70 M50 70 V90" />
      {label ? <text x={50} y={48} textAnchor="middle" fontSize={19} fontFamily={SANS} fontWeight={800} fill={T.accent} stroke="none">{label}</text> : <path d="M22 32 H50 M22 46 H64" stroke={T.accent} />}
    </svg>
  );
};

// ---------- элементы интерфейса программ ----------
// указатель мыши; click 0..1 – расходящееся кольцо нажатия
export const Cursor: React.FC<{ x: number; y: number; click: number; o?: number }> = ({ x, y, click, o = 1 }) => (
  <div style={{ position: "absolute", left: x, top: y, opacity: o }}>
    {click > 0 && click < 1 ? <div style={{ position: "absolute", left: 4 - 40 * click, top: 4 - 40 * click, width: 80 * click, height: 80 * click, borderRadius: 40 * click, border: "4px solid #fff", opacity: 1 - click }} /> : null}
    <svg width={58} height={58} viewBox="0 0 24 24"><path d="M4 2 L4 20 L9 15.5 L12.2 22 L15.2 20.6 L12 14.2 L18.6 14.2 Z" fill="#fff" stroke="#111" strokeWidth={1.3} strokeLinejoin="round" /></svg>
  </div>
);
// путь курсора по ключевым кадрам [кадр, x, y] (кадры по возрастанию)
export const usePath = (kf: [number, number, number][]) => {
  const f = useCurrentFrame();
  const opt = { ...clamp, easing: Easing.inOut(Easing.cubic) };
  return { x: interpolate(f, kf.map((k) => k[0]), kf.map((k) => k[1]), opt), y: interpolate(f, kf.map((k) => k[0]), kf.map((k) => k[2]), opt) };
};
export const clickAt = (f: number, a: number) => interpolate(f, [a, a + 12], [0, 1], clamp);
export const WinBar: React.FC<{ h?: number }> = ({ h = 60 }) => {
  const T = useTheme();
  return <div style={{ height: h, borderBottom: `2px solid ${T.line}`, display: "flex", alignItems: "center", gap: 12, padding: "0 26px" }}>{[T.red, T.accent, T.green].map((c) => <div key={c} style={{ width: 18, height: 18, borderRadius: 9, background: c, opacity: 0.8 }} />)}</div>;
};
export const Toggle: React.FC<{ on: number }> = ({ on }) => {
  const T = useTheme();
  return <div style={{ position: "relative", width: 112, height: 62, borderRadius: 31, background: on > 0.5 ? T.accent : T.line, border: `2px solid ${T.line}`, boxSizing: "border-box" }}><div style={{ position: "absolute", left: 6 + 50 * on, top: 6, width: 46, height: 46, borderRadius: 23, background: on > 0.5 ? T.bg : T.text }} /></div>;
};
// шапка шага: номер, название и точки хода «шаг n из total»
export const StepHead: React.FC<{ n: number; total?: number; title?: string; o: number }> = ({ n, total = 4, title, o }) => {
  const T = useTheme();
  return (
    <div style={{ position: "absolute", left: 90, top: 150, width: 900, height: 96, display: "flex", alignItems: "center", gap: 28, opacity: o }}>
      <div style={{ width: 96, height: 96, borderRadius: 48, background: T.accent, color: T.bg, font: `800 58px/96px ${SANS}`, textAlign: "center", flexShrink: 0 }}>{n}</div>
      {title ? <div style={{ font: `800 70px/1 ${SANS}`, color: T.text, whiteSpace: "nowrap" }}>{title}</div> : null}
      <div style={{ marginLeft: "auto", display: "flex", gap: 14 }}>{Array.from({ length: total }, (_, k) => k + 1).map((i) => <div key={i} style={{ width: 30, height: 30, borderRadius: 15, background: i <= n ? T.accent : "transparent", border: `4px solid ${i <= n ? T.accent : T.soft}`, opacity: i < n ? 0.55 : 1, boxSizing: "border-box" }} />)}</div>
    </div>
  );
};
```

<!-- file: src/reel/Layout.tsx -->
```tsx
// Три раскладки кадра, субтитры, сцена со свечением. Под ролик не меняется: всё, что зависит от ролика, лежит в scenes.tsx.
import React from "react";
import { AbsoluteFill, Easing, OffthreadVideo, interpolate, staticFile, useCurrentFrame } from "remotion";
import { D, PALETTES, SANS, Sub, Theme, ThemeProvider, clamp, useTheme } from "./kit";
import { BOX, MODE, PALETTE, SCENES, SPEAKER } from "./scenes";

export const REEL_FRAMES = D.frames;
export const sceneAt = (f: number) => { let k = 0; for (let i = 0; i < SCENES.length; i++) if (f >= SCENES[i].from) k = i; return k; };

// сцена 1080×1000 со свечением; пятно переезжает к новой сцене за 18 кадров
const Stage: React.FC = () => {
  const f = useCurrentFrame();
  const T = useTheme();
  const k = sceneAt(f), S = SCENES[k], P = SCENES[Math.max(0, k - 1)];
  const p = interpolate(f, [S.from, S.from + 18], [0, 1], { ...clamp, easing: Easing.inOut(Easing.cubic) });
  const gx = P.gx + (S.gx - P.gx) * p, gy = P.gy + (S.gy - P.gy) * p;
  return (
    <div style={{ position: "absolute", left: 0, top: 0, width: 1080, height: 1000 }}>
      <div style={{ position: "absolute", left: -200, top: -200, width: 1480, height: 1400, background: `radial-gradient(520px 420px at ${(gx / 100) * 1080 + 200}px ${(gy / 100) * 1000 + 200}px, rgba(${T.glow},0.30), rgba(${T.glow},0.08) 55%, rgba(0,0,0,0) 75%)` }} />
      <S.C />
    </div>
  );
};
const Staged: React.FC<{ theme: Theme; x: number; y: number; scale: number }> = ({ theme, x, y, scale }) => (
  <ThemeProvider value={theme}>
    <div style={{ position: "absolute", left: x, top: y, width: 1080 * scale, height: 1000 * scale, overflow: "hidden" }}>
      <div style={{ width: 1080, height: 1000, transform: `scale(${scale})`, transformOrigin: "0 0" }}><Stage /></div>
    </div>
  </ThemeProvider>
);
// человек: кадр 1080×1920 (base.mp4 1152×2048 показывается в размер), y0 – с какой высоты кадра показывать, scale – увеличение от верха
const Speaker: React.FC<{ top: number; height: number; y0: number; fade?: string; scale?: number }> = ({ top, height, y0, fade, scale = 1 }) => (
  <div style={{ position: "absolute", left: 0, top, width: 1080, height, overflow: "hidden" }}>
    <div style={{ position: "absolute", left: (1080 - 1080 * scale) / 2, top: -y0, width: 1080 * scale, height: 1920 * scale }}>
      <OffthreadVideo muted src={staticFile("base.mp4")} style={{ width: "100%", height: "100%" }} />
    </div>
    {fade ? <div style={{ position: "absolute", left: 0, top: 0, width: 1080, height: 90, background: `linear-gradient(to bottom, ${fade}, rgba(0,0,0,0))` }} /> : null}
  </div>
);
// Субтитры: фраза целиком или ничего (hide); плашка только поверх видео и в цвет карточки палитры
const Subs: React.FC<{ list: Sub[]; y: number; size: number; plate?: Theme; color: string }> = ({ list, y, size, plate, color }) => {
  const f = useCurrentFrame();
  const s = list.find((x) => f >= x.from && f < x.to);
  if (!s || s.hide) return null;
  return (
    <div style={{ position: "absolute", left: 0, top: y - size * 0.85, width: 1080, display: "flex", justifyContent: "center" }}>
      <div style={{ background: plate ? `linear-gradient(180deg, ${plate.hi}, ${plate.card})` : "transparent", border: plate ? `2px solid ${plate.line}` : undefined, boxShadow: plate ? "0 14px 34px rgba(0,0,0,0.35)" : undefined, color, font: `800 ${size}px/1 ${SANS}`, padding: `${size * 0.34}px ${size * 0.56}px ${size * 0.4}px`, borderRadius: size * 0.3, whiteSpace: "nowrap" }}>{s.text}</div>
    </div>
  );
};
const STARS = Array.from({ length: 90 }, (_, i) => { const r = (n: number) => { const v = Math.sin(i * 127.1 + n * 311.7) * 43758.5453; return v - Math.floor(v); }; return { x: r(1) * 1080, y: r(2) * 1920, s: 2 + r(3) * 4, o: 0.25 + r(4) * 0.6, ph: r(5) * 6 }; });
const Stars: React.FC = () => {
  const f = useCurrentFrame();
  return <>{STARS.map((s, i) => <div key={i} style={{ position: "absolute", left: s.x, top: s.y, width: s.s, height: s.s, borderRadius: s.s, background: "#fff3e6", opacity: s.o * (0.6 + 0.4 * Math.sin(f / 14 + s.ph)) }} />)}</>;
};
// карточка под подбородком: сцена вписывается в неё по рамке содержимого BOX, внутренних рамок нет (flat)
const CARD = { left: 190, top: 1050, w: 700, h: 420 };
const OverCard: React.FC<{ theme: Theme }> = ({ theme }) => {
  const f = useCurrentFrame();
  const S = SCENES[sceneAt(f)];
  const [x0, y0, x1, y1] = BOX[S.name];
  const sc = Math.min((CARD.w - 56) / (x1 - x0), (CARD.h - 48) / (y1 - y0), 0.86);
  return (
    <div style={{ position: "absolute", left: CARD.left, top: CARD.top, width: CARD.w, height: CARD.h, borderRadius: 36, background: theme.bg, border: `2px solid ${theme.line}`, boxShadow: "0 24px 60px rgba(0,0,0,0.45)", overflow: "hidden" }}>
      <Staged theme={{ ...theme, flat: true }} x={CARD.w / 2 - ((x0 + x1) / 2) * sc} y={CARD.h / 2 - ((y0 + y1) / 2) * sc} scale={sc} />
    </div>
  );
};
const SPLIT = 770, SPLIT_SC = 0.72;
export const Reel: React.FC = () => {
  const f = useCurrentFrame();
  const T = PALETTES[PALETTE];
  const mode = MODE[SCENES[sceneAt(f)].name];
  return (
    <AbsoluteFill style={{ background: T.bg }}>
      {mode === "split" ? (
        <>
          <Speaker top={SPLIT} height={1920 - SPLIT} y0={SPEAKER.splitY0} fade={`${T.bg}cc`} />
          <div style={{ position: "absolute", left: 0, top: 0, width: 1080, height: SPLIT, background: `radial-gradient(900px 700px at 50% 30%, ${T.hi}, ${T.bg} 70%)`, overflow: "hidden" }}>
            <Staged theme={T} x={(1080 - 1080 * SPLIT_SC) / 2} y={0} scale={SPLIT_SC} />
          </div>
          <Subs list={D.subs} y={708} size={50} color={T.text} />
        </>
      ) : mode === "full" ? (
        <>
          <div style={{ position: "absolute", inset: 0, background: `radial-gradient(1100px 900px at 50% 42%, ${T.hi2}, ${T.deep} 75%)` }} />
          <Stars />
          <Staged theme={T} x={0} y={330} scale={1} />
          <Subs list={D.subs} y={1360} size={54} color={T.text} />
        </>
      ) : (
        <>
          <Speaker top={0} height={1920} y0={SPEAKER.overShift} scale={SPEAKER.overScale} />
          <OverCard theme={T} />
          <Subs list={D.subs} y={985} size={44} plate={T} color={T.text} />
        </>
      )}
    </AbsoluteFill>
  );
};
```

<!-- file: src/reel/scenes.tsx -->
```tsx
// Сцены ЭТОГО ролика. Пишется заново под каждый текст. Номера слов – из `python3 tools/gen_data.py --list`.
// Ниже рабочий образец на пять сцен: замени номера слов, тексты и предметы под свою речь.
import React from "react";
import { useCurrentFrame } from "remotion";
import { Badge, Balloon, Bubble, Check, Cross, Cursor, Doc, Dots, Folder, Lines, Monitor, Person, Pill, SANS, StepHead, WinBar, Wavy, at, cardStyle, clickAt, en, pop, rise, springAt, tileStyle, up, usePath, usePop, useTheme, useTyped, useWords } from "./kit";

export const PALETTE = "graphite";                                     // раздел 8: ключ палитры
export const SPEAKER = { splitY0: 390, overShift: 115, overScale: 1.06 }; // раздел 7: подогнать под кадр

// 1. Хук: первая фраза печатается тремя строками, под ней две плитки «✓ / ✕» (раскладка over)
const Hook: React.FC = () => {
  const T = useTheme();
  const t = useWords(0, 8, [4, 6]);                 // слова 0–8, перенос после 4-го и 6-го
  const left = usePop(at(3)), ok = usePop(at(3) + 8), right = usePop(at(7)), no = usePop(at(7) + 6);
  return (
    <>
      <div style={{ position: "absolute", left: 72, top: 190 }}><Lines text={t.text} typing={t.typing} st={[{ size: 66, color: T.text }, { size: 66, color: T.text }, { size: 66, color: T.accent }]} /></div>
      <div style={{ position: "absolute", left: 200, top: 470, display: "flex", gap: 100 }}>
        <div style={{ ...tileStyle(T, 210), ...pop(left) }}><Bubble size={140} color={T.text} /><Badge color={T.green} scale={ok}><Check size={58} color="#fff" /></Badge></div>
        <div style={{ ...tileStyle(T, 210), ...pop(right) }}><Person size={130} color={T.text} /><Badge color={T.red} scale={no}><Cross size={46} color="#fff" /></Badge></div>
      </div>
    </>
  );
};

// 2. Цифра: крупное число на своём слове, подпись и ряд плиток (раскладка over)
const BigNumber: React.FC = () => {
  const T = useTheme();
  const f = useCurrentFrame();
  const big = usePop(at(18) - 2), word = up(f, at(19), 7);
  return (
    <>
      <div style={{ position: "absolute", left: 90, top: 170, font: `800 220px/1 ${SANS}`, color: T.accent, transformOrigin: "0% 100%", ...pop(big, 0.6) }}>4</div>
      <div style={{ position: "absolute", left: 270, top: 268, font: `800 96px/1 ${SANS}`, color: T.text, ...rise(word) }}>настройки</div>
      {[Doc, Folder, Bubble, Monitor].map((Ic, i) => (
        <div key={i} style={{ position: "absolute", left: 90 + i * 236, top: 440, ...pop(springAt(f, at(20) + i * 5)) }}><div style={tileStyle(T, 196)}><Ic size={110} color={T.text} /></div></div>
      ))}
    </>
  );
};

// 3. Окно программы и курсор: нажатие на слове действия, затем появляется поле (раскладка split)
const AppWindow: React.FC = () => {
  const T = useTheme();
  const f = useCurrentFrame();
  const a = up(f, at(27), 8), page = up(f, at(38) + 4, 7);
  const c = usePath([[at(30) - 8, 760, 700], [at(33), 150, 790], [at(37), 150, 790], [at(38), 600, 560]]);
  const click = Math.max(clickAt(f, at(33)), clickAt(f, at(38)));
  return (
    <>
      <StepHead n={1} title="шаг" o={a} />
      <div style={{ position: "absolute", left: 90, top: 290, width: 900, height: 580, ...cardStyle(T), overflow: "hidden", ...rise(a, 24) }}>
        <WinBar />
        <div style={{ position: "absolute", left: 0, top: 60, width: 250, bottom: 0, background: T.bg, borderRight: `2px solid ${T.line}` }}>
          {[0, 1, 2, 3].map((i) => <div key={i} style={{ position: "absolute", left: 26, top: 30 + i * 56, width: 170 - i * 20, height: 24, borderRadius: 8, background: T.line }} />)}
          <div style={{ position: "absolute", left: 26, bottom: 30, width: 64, height: 64, borderRadius: 32, background: T.accent }} />
        </div>
        <div style={{ position: "absolute", left: 290, top: 100, right: 40, opacity: page }}>
          <div style={{ font: `800 50px/1 ${SANS}`, color: T.text }}>Settings</div>
          <div style={{ marginTop: 30, height: 220, borderRadius: 20, border: `3px solid ${T.accent}`, background: T.bg }} />
        </div>
      </div>
      <Cursor x={c.x} y={c.y} click={click} o={up(f, at(30) - 8, 6)} />
    </>
  );
};

// 4. Цитата в облачке: три точки, пока цитата не началась, потом она печатается (раскладка full)
const Quote: React.FC = () => {
  const T = useTheme();
  const f = useCurrentFrame();
  const a = up(f, at(45), 8);
  const q = useWords(47, 55, [50]);
  return (
    <>
      <div style={{ position: "absolute", left: 90, top: 160, ...rise(a, 20) }}><div style={tileStyle(T, 220)}><Person size={160} color={T.text} /></div></div>
      <Balloon x0={90} y0={460} x1={990} y1={800} tx={150} o={a} />
      {f < at(47) ? <Dots x={140} y={600} o={a} /> : null}
      <div style={{ position: "absolute", left: 134, top: 500 }}><Lines text={q.text} typing={q.typing} st={[{ size: 62, color: T.text }, { size: 62, color: T.accent }]} lh={1.2} /></div>
    </>
  );
};

// 5. Финал: мораль печатается, главное акцентом, волнистая черта (раскладка over)
const Final: React.FC = () => {
  const T = useTheme();
  const f = useCurrentFrame();
  const last = 60;                                       // номер последнего слова морали
  const t = useTyped([[56, "Главная"], [57, " мысль"], [58, "\nролика"], [59, " вот"], [last, " такая"]]);
  return (
    <div style={{ position: "absolute", left: 72, top: 260 }}>
      <Lines text={t.text} typing={t.typing} st={[{ size: 80, color: T.text }, { size: 80, color: T.accent }]} />
      <div style={{ marginTop: 6 }}><Wavy w={520} p={up(f, en(last) - 4, 10)} color={T.accent} /></div>
    </div>
  );
};

export type Mode = "over" | "split" | "full";
// расписание: сцена начинается с кадра from; gx, gy – где свечение (проценты сцены)
export const SCENES: { from: number; name: string; C: React.FC; gx: number; gy: number }[] = [
  { from: 0, name: "hook", C: Hook, gx: 50, gy: 34 },
  { from: at(17), name: "number", C: BigNumber, gx: 30, gy: 30 },
  { from: at(27), name: "app", C: AppWindow, gx: 40, gy: 60 },
  { from: at(45), name: "quote", C: Quote, gx: 50, gy: 50 },
  { from: at(56), name: "final", C: Final, gx: 44, gy: 40 },
];
export const MODE: Record<string, Mode> = { hook: "over", number: "over", app: "split", quote: "full", final: "over" };
// рамка содержимого каждой сцены [x0, y0, x1, y1] на сцене 1080×1000 – по ней сцена вписывается в карточку раскладки over
export const BOX: Record<string, [number, number, number, number]> = {
  hook: [72, 190, 1008, 690], number: [72, 170, 1008, 640], app: [72, 140, 1008, 880], quote: [72, 150, 1008, 820], final: [72, 250, 1008, 470],
};
```

<!-- file: tools/gen_data.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Данные ролика: основа видео, голос, кадры слов, фразы, субтитры. Настройки в work/edit.json (раздел 6 скилла).
python3 tools/gen_data.py --list   печать слов с номерами и кадрами и фраз
python3 tools/gen_data.py --base   пересобрать основу видео и голос принудительно"""
import json, math, os, re, subprocess, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FF = os.environ.get("FFMPEG", "ffmpeg"); FP = os.environ.get("FFPROBE", "ffprobe"); FPS = 30
CFG = json.load(open(os.path.join(ROOT, "work", "edit.json"), encoding="utf-8"))
SRC = CFG["source"]
BASE = os.path.join(ROOT, "public", "base.mp4"); VOICE = os.path.join(ROOT, "work", "voice.wav")
SEGF = os.path.join(ROOT, "work", "segments.json")
GRADE = "curves=all='0/0 0.25/0.245 0.55/0.60 0.83/0.90 1/1',eq=saturation=1.04,vignette=angle=PI/7"
os.makedirs(os.path.join(ROOT, "public"), exist_ok=True)

def run(a): subprocess.run([FF, "-nostdin", "-y", "-v", "error"] + a, check=True)
def probe(args, path): return subprocess.run([FP, "-v", "error"] + args + ["-of", "default=nw=1:nk=1", path], stdout=subprocess.PIPE, text=True).stdout.strip()

# 1. тишина в исходнике (по ней поправляется начало слов после пауз)
out = subprocess.run([FF, "-nostdin", "-hide_banner", "-i", SRC, "-vn", "-af", "silencedetect=noise=-40dB:d=0.2", "-f", "null", "-"], stderr=subprocess.PIPE).stderr.decode(errors="ignore")
sil, cs = [], None
for m in re.finditer(r"silence_(start|end): ([\d.]+)", out):
    if m.group(1) == "start": cs = float(m.group(2))
    elif cs is not None: sil.append((cs, float(m.group(2)))); cs = None

# 2. слова: правки fix / merge / drop (номера по СЫРОЙ расшифровке)
d = json.load(open(os.path.join(ROOT, CFG["transcript"]), encoding="utf-8"))
raw = [w for s in d["segments"] for w in s.get("words", [])]
fix = {int(k): v for k, v in CFG.get("fix", {}).items()}; merge = set(CFG.get("merge", [])); drop = set(CFG.get("drop", []))
ws = []
for i, w in enumerate(raw):
    t = fix.get(i, w["word"].strip()); a, b = float(w["start"]), float(w["end"])
    if i in drop: continue
    if i in merge and ws: ws[-1]["end"] = b; continue
    for s_, e in sil:                        # Whisper растягивает слово на паузу перед ним: начало = конец тишины
        if s_ <= a + 0.06 and a < e < b: a = max(a, e - 0.03)
    ws.append({"t": t, "start": a, "end": b, "raw": i})
# предупреждения о зацикливании расшифровки
for i in range(len(raw) - 3):
    if len({round(float(raw[k]["start"]), 2) for k in range(i, i + 4)}) == 1:
        print(f"⚠️  слова {i}–{i + 3} сырой расшифровки начинаются в одно время ({raw[i]['start']}): похоже на зацикливание Whisper"); break
for i, w in enumerate(raw):
    if re.search(r"(?i)субтитр|продолжение следует|подписывайтесь|redactor|dimatorzok", w["word"]): print(f"⚠️  слово {i} «{w['word'].strip()}» похоже на выдумку Whisper: проверь и убери через drop")

# 3. шкала времени: куски исходника, которые остаются (в кадрах)
dur = float(probe(["-show_entries", "format=duration"], SRC))
t0, t1 = max(0.0, ws[0]["start"] - 0.09), min(dur - 0.02, ws[-1]["end"] + 0.2)
thr = float(CFG.get("cut_pauses_over", 0) or 0)
cuts = [[t0, None]]
if thr > 0:
    for p, n in zip(ws, ws[1:]):
        if n["start"] - p["end"] > thr: cuts[-1][1] = p["end"] + 0.12; cuts.append([n["start"] - 0.12, None])
cuts[-1][1] = t1
segs = [(int(math.floor(a * FPS)), int(math.ceil(b * FPS))) for a, b in cuts]
offs, acc = [], 0
for a, b in segs: offs.append(acc); acc += b - a
FRAMES = acc

def fr(t):
    x = t * FPS
    for (a, b), o in zip(segs, offs):
        if x < a: return o                    # попало в вырезанную паузу: к началу следующего куска
        if x < b: return int(round(o + x - a))
    return FRAMES - 1

# 4. основа видео и голос одним проходом, по одним и тем же кадрам
need = "--base" in sys.argv or not os.path.exists(BASE) or not os.path.exists(VOICE) or not os.path.exists(SEGF) or json.load(open(SEGF)) != {"src": SRC, "segs": segs, "grade": CFG.get("grade", True)}
if need:
    n = len(segs); g = ("," + GRADE) if CFG.get("grade", True) else ""
    fc = [f"[0:v]fps={FPS},split={n}" + "".join(f"[v{i}]" for i in range(n)), "[0:a]aresample=48000,asplit=" + str(n) + "".join(f"[a{i}]" for i in range(n))]
    for i, (a, b) in enumerate(segs):
        fc.append(f"[v{i}]trim=start_frame={a}:end_frame={b},setpts=PTS-STARTPTS[w{i}]")
        fc.append(f"[a{i}]atrim=start={a / FPS:.6f}:end={b / FPS:.6f},asetpts=PTS-STARTPTS[x{i}]")
    fc.append("".join(f"[w{i}][x{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=1[cv][ca]")
    fc.append(f"[cv]scale=1152:2048:force_original_aspect_ratio=increase:flags=lanczos,crop=1152:2048{g},format=yuv420p[vo]")
    print(f"собираю основу: {n} кус., {FRAMES} кадров = {FRAMES / FPS:.2f} с …")
    run(["-i", SRC, "-filter_complex", ";".join(fc),
         "-map", "[vo]", "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "13", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", BASE,
         "-map", "[ca]", "-vn", "-ac", "1", "-c:a", "pcm_s16le", VOICE])
    nf = int(probe(["-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames"], BASE))
    vd = float(probe(["-show_entries", "format=duration"], VOICE))
    assert nf == FRAMES, f"в основе {nf} кадров, а должно быть {FRAMES}"
    assert abs(vd - FRAMES / FPS) < 0.05, f"голос {vd:.3f} с, видео {FRAMES / FPS:.3f} с: рассинхрон"
    json.dump({"src": SRC, "segs": segs, "grade": CFG.get("grade", True)}, open(SEGF, "w"))

# 5. фразы: номер последнего слова каждой (ends); если не заданы – черновик по знакам препинания
def clean(t): return t.strip(".,!?«»:;…\"")
ends = CFG.get("ends") or []
if not ends:
    st = 0
    for i, w in enumerate(ws):
        if (re.search(r"[.!?…:;,»]$", w["t"]) and i - st >= 1) or i - st >= 7: ends.append(i); st = i + 1
    if not ends or ends[-1] != len(ws) - 1: ends.append(len(ws) - 1)
    print("⚠️  ends не заданы: фразы разбиты черновиком по знакам препинания, поправь в edit.json")
assert ends == sorted(ends) and ends[-1] == len(ws) - 1, f"ends: последнее слово должно быть {len(ws) - 1}"

# 6. субтитры: фраза делится на куски по 2–5 слов, не длиннее ~24 знаков, кусок не кончается предлогом
GLUE = {"в", "на", "к", "с", "и", "а", "но", "что", "как", "не", "из", "от", "у", "за", "их", "его", "бы", "чем", "же", "до", "он", "я", "им", "было", "для", "ещё", "вы", "под", "перед", "со", "по", "при", "над", "о", "об", "про", "без", "через"}
def split_cue(cue):
    n = len(cue); best = {0: (0, [])}
    for i in range(1, n + 1):
        cand = None
        for k in (1, 2, 3, 4, 5):
            if i - k < 0: continue
            part = cue[i - k:i]; chars = len(" ".join(clean(w["t"]) for w in part)); pen = best[i - k][0]
            if chars > 24: pen += 20
            if k == 5: pen += 6
            if k == 1 and n > 1: pen += 100
            if i < n and clean(part[-1]["t"]).lower() in GLUE: pen += 10
            pen += abs(k - min(3, n)) * 0.5 + 0.03 * abs(chars - 14)
            if cand is None or pen < cand[0]: cand = (pen, best[i - k][1] + [k])
        best[i] = cand
    out, pos = [], 0
    for k in best[n][1]: out.append(cue[pos:pos + k]); pos += k
    return out
SHOW, WHOLE = set(CFG.get("show", [])), set(CFG.get("whole", []))
subs, phrases, a = [], [], 0
for ci, e in enumerate(ends):
    cue = ws[a:e + 1]; a = e + 1; phrases.append(" ".join(clean(w["t"]) for w in cue))
    for part in ([cue] if ci in WHOLE else split_cue(cue)):
        subs.append({"from": fr(part[0]["start"]), "to": fr(part[-1]["end"]), "text": " ".join(clean(w["t"]) for w in part), "hide": ci not in SHOW})
for i in range(len(subs) - 1): subs[i]["to"] = min(subs[i]["to"] + 3, subs[i + 1]["from"])
shown = [s["text"] for s in subs if not s["hide"]]
for t in shown:
    if len(t.split()) < 2: print(f"⚠️  субтитр из одного слова: «{t}» – добавь фразу в whole или пересобери фразы")

json.dump({"frames": FRAMES, "ws": [fr(w["start"]) for w in ws], "we": [fr(w["end"]) for w in ws], "wt": [w["t"] for w in ws], "subs": subs},
          open(os.path.join(ROOT, "src", "reel", "data.json"), "w", encoding="utf-8"), ensure_ascii=False)
print(f"кадров {FRAMES} = {FRAMES / FPS:.2f} с | слов {len(ws)} | фраз {len(ends)} | субтитров показано {len(shown)}")
if "--list" in sys.argv:
    print("  ".join(f"{i}:{w['t']}@{fr(w['start'])}" for i, w in enumerate(ws)))
    for i, p in enumerate(phrases): print(("👁 " if i in SHOW else "   ") + f"{i} {p}")
```

<!-- file: tools/transcribe.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Расшифровка по словам через faster-whisper (Windows, Linux, Intel Mac). Выход в формате mlx-whisper.
python3 tools/transcribe.py work/a16.wav work/run1/a16.json large-v3 "подсказка: начало сценария, имена, термины" """
import json, os, sys
from faster_whisper import WhisperModel

src, dst = sys.argv[1], sys.argv[2]
model = sys.argv[3] if len(sys.argv) > 3 else "large-v3"
prompt = sys.argv[4] if len(sys.argv) > 4 else None
m = WhisperModel(model, device="auto", compute_type="int8")
segs, _ = m.transcribe(src, language="ru", word_timestamps=True, initial_prompt=prompt, condition_on_previous_text=False, vad_filter=False)
out = {"segments": [{"text": s.text, "words": [{"word": w.word, "start": w.start, "end": w.end} for w in (s.words or [])]} for s in segs]}
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
json.dump(out, open(dst, "w", encoding="utf-8"), ensure_ascii=False)
print("слов:", sum(len(s["words"]) for s in out["segments"]))
```

<!-- file: tools/make_hits.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Три коротких удара для акцентов (синтез, без чужих сэмплов): tools/hits/hit_a.wav, hit_b.wav, hit_c.wav. Пик −1 dBFS."""
import math, os, random, struct, wave

SR = 48000; OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hits"); os.makedirs(OUT, exist_ok=True)
random.seed(7)
def env(t, T, att, dec): return min(1.0, t / att) * math.exp(-t * dec) * min(1.0, (T - t) / 0.02)
def write(name, xs):
    pk = max(abs(x) for x in xs) or 1.0; g = 10 ** (-1 / 20) / pk
    with wave.open(os.path.join(OUT, name), "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
        w.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, x * g)) * 32767)) for x in xs))
# a: мягкий низкий удар с коротким щелчком в начале
T = 0.35; xs, ph = [], 0.0
for n in range(int(T * SR)):
    t = n / SR; f = 55 * (1 + 1.6 * math.exp(-t * 28)); ph += 2 * math.pi * f / SR
    xs.append(math.sin(ph) * env(t, T, 0.003, 9) + random.uniform(-1, 1) * math.exp(-t * 380) * 0.35)
write("hit_a.wav", xs)
# b: короткий «тук»: тело 170 Гц и приглушённый шорох
T = 0.22; xs, ph, lp = [], 0.0, 0.0
for n in range(int(T * SR)):
    t = n / SR; ph += 2 * math.pi * 170 / SR; lp += 0.08 * (random.uniform(-1, 1) - lp)
    xs.append(math.sin(ph) * env(t, T, 0.002, 24) * 0.9 + lp * env(t, T, 0.001, 55) * 1.6)
write("hit_b.wav", xs)
# c: глубокий «бум» для финала
T = 0.40; xs, p1, p2 = [], 0.0, 0.0
for n in range(int(T * SR)):
    t = n / SR; f = 46 * (1 + 0.8 * math.exp(-t * 18)); p1 += 2 * math.pi * f / SR; p2 += 2 * math.pi * 2 * f / SR
    xs.append((math.sin(p1) + 0.3 * math.sin(p2)) * env(t, T, 0.005, 6.5))
write("hit_c.wav", xs)
print("готово:", ", ".join(sorted(os.listdir(OUT))))
```

<!-- file: tools/build_audio.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Звук: голос work/voice.wav → обработка → −17 LUFS моно (= −14 в стерео) → удары из work/sfx.json → work/audio_final.wav."""
import glob, json, os, re, subprocess

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))); W = os.path.join(ROOT, "work")
FF = os.environ.get("FFMPEG", "ffmpeg"); FPS = 30
def run(a): return subprocess.run([FF, "-nostdin", "-hide_banner", "-y"] + a, stderr=subprocess.PIPE).stderr.decode(errors="ignore")
chain = ",".join([
    "highpass=f=80", "afftdn=nr=6:nf=-55",
    "equalizer=f=300:t=q:w=1:g=-2.5", "equalizer=f=3500:t=q:w=0.9:g=3", "treble=g=3:f=8000",
    "deesser=i=0.35",
    "acompressor=threshold=-24dB:ratio=3:attack=10:release=120",
    "alimiter=limit=-1.5dB:level=disabled",
])
run(["-i", os.path.join(W, "voice.wav"), "-af", chain, "-ar", "48000", os.path.join(W, "_fx.wav")])
def lufs(p):
    m = run(["-i", p, "-af", "ebur128", "-f", "null", "-"])
    return float(re.search(r"Integrated loudness:.*?I:\s+(-?[\d.]+) LUFS", m, re.S).group(1))
src = os.path.join(W, "_fx.wav")
for step in range(3):
    cur = lufs(src); g = -17.0 - cur
    print(f"проход {step}: {cur:.1f} LUFS (моно), усиление {g:+.1f} дБ")
    if abs(g) < 0.15: break
    dst = os.path.join(W, f"_n{step}.wav")
    run(["-i", src, "-af", f"volume={g:.2f}dB,alimiter=limit=-1.5dB:attack=5:release=60:level=disabled", "-ar", "48000", dst]); src = dst
data = json.load(open(os.path.join(ROOT, "src", "reel", "data.json"), encoding="utf-8"))
sfx = json.load(open(os.path.join(W, "sfx.json"), encoding="utf-8")) if os.path.exists(os.path.join(W, "sfx.json")) else []
ins, fc, labs = ["-i", src], [], ["[0]"]
for i, s in enumerate(sfx):
    t = (data["ws"][s["word"]] + s.get("shift", 0)) / FPS
    ins += ["-i", os.path.join(ROOT, "tools", "hits", f"hit_{s['hit']}.wav")]
    fc.append(f"[{i + 1}]volume=-7dB,adelay={int(round(t * 1000))}:all=1[s{i}]"); labs.append(f"[s{i}]")
    print(f"удар {s['hit']} на слове {s['word']} «{data['wt'][s['word']]}», {t:.2f} с")
fc.append(f"{''.join(labs)}amix=inputs={len(labs)}:normalize=0:duration=first,alimiter=limit=-1.2dB:level=disabled,pan=stereo|c0=c0|c1=c0[out]")
run(ins + ["-filter_complex", ";".join(fc), "-map", "[out]", "-ar", "48000", "-c:a", "pcm_s16le", os.path.join(W, "audio_final.wav")])
m = run(["-i", os.path.join(W, "audio_final.wav"), "-af", "ebur128=peak=true", "-f", "null", "-"])
st = [float(b) for a, b in re.findall(r"t:\s*([\d.]+)\s+TARGET.*?S:\s*(-?[\d.]+)", m) if float(a) > 3 and float(b) > -60]
print("итог:", re.search(r"Integrated loudness:.*?I:\s+(-?[\d.]+ LUFS)", m, re.S).group(1), "| истинный пик:", re.findall(r"Peak:\s+(-?[\d.]+) dBFS", m)[-1], "dBFS",
      "| разброс кратковременной громкости: %.1f LU (норма до 5)" % (max(st) - min(st)))
for f in glob.glob(os.path.join(W, "_*.wav")): os.remove(f)
```

<!-- file: tools/render.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Рендер без звука → сведение с work/audio_final.wav → проверка → листы кадров всего ролика в out/check/.
python3 tools/render.py            весь ролик
python3 tools/render.py 0-299      только кадры 0–299 (быстрая проба)"""
import glob, json, os, re, shutil, subprocess, sys, tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FF = os.environ.get("FFMPEG", "ffmpeg"); FP = os.environ.get("FFPROBE", "ffprobe")
CFG = json.load(open(os.path.join(ROOT, "work", "edit.json"), encoding="utf-8"))
os.makedirs(os.path.join(ROOT, "out", "check"), exist_ok=True)
def clean_tmp():                                   # каждый рендер оставляет временную сборку на сотни МБ
    for d in glob.glob(os.path.join(tempfile.gettempdir(), "remotion-webpack-bundle-*")): shutil.rmtree(d, ignore_errors=True)
clean_tmp()
raw = os.path.join(ROOT, "out", "raw.mp4")
cmd = [shutil.which("npx") or "npx", "remotion", "render", "src/index.ts", "Reel", raw, "--muted", "--color-space", "bt709",
       "--jpeg-quality", "92", "--crf", "12", "--concurrency", os.environ.get("CONCURRENCY", "4")]
if len(sys.argv) > 1: cmd += ["--frames", sys.argv[1]]
subprocess.run(cmd, cwd=ROOT, check=True, shell=(os.name == "nt"))
clean_tmp()
final = os.path.join(ROOT, "out", CFG.get("out_name", "reel.mp4"))
subprocess.run([FF, "-nostdin", "-y", "-v", "error", "-i", raw, "-i", os.path.join(ROOT, "work", "audio_final.wav"), "-map", "0:v", "-map", "1:a",
                "-vf", "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709,format=yuv420p",
                "-c:v", "libx264", "-preset", "slow", "-crf", "17", "-maxrate", "12M", "-bufsize", "24M", "-g", "30", "-profile:v", "high", "-level", "4.2",
                "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
                "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-shortest", "-movflags", "+faststart", final], check=True)
os.remove(raw)
# проверка: полное декодирование, кадры, громкость
err = subprocess.run([FF, "-nostdin", "-v", "error", "-i", final, "-f", "null", "-"], stderr=subprocess.PIPE, text=True).stderr.strip()
nf = subprocess.run([FP, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames", "-of", "default=nw=1:nk=1", final], stdout=subprocess.PIPE, text=True).stdout.strip()
m = subprocess.run([FF, "-nostdin", "-hide_banner", "-i", final, "-af", "ebur128=peak=true", "-f", "null", "-"], stderr=subprocess.PIPE, text=True).stderr
print("файл:", final, "| %.1f МБ" % (os.path.getsize(final) / 1e6), "| кадров:", nf, "| ошибки декодирования:", err or "нет")
print("громкость:", re.search(r"Integrated loudness:.*?I:\s+(-?[\d.]+ LUFS)", m, re.S).group(1), "| истинный пик:", re.findall(r"Peak:\s+(-?[\d.]+) dBFS", m)[-1], "dBFS")
# листы кадров всего ролика: кадр каждые 1,5 с, по 20 на лист
for f in glob.glob(os.path.join(ROOT, "out", "check", "list_*.jpg")): os.remove(f)
subprocess.run([FF, "-nostdin", "-y", "-v", "error", "-i", final, "-vf", "fps=2/3,scale=324:576,tile=10x2", "-q:v", "3", os.path.join(ROOT, "out", "check", "list_%d.jpg")], check=True)
print("листы:", ", ".join(sorted(os.path.basename(p) for p in glob.glob(os.path.join(ROOT, "out", "check", "list_*.jpg")))))
```

<!-- file: tools/stills.mjs -->
```js
// Кадры-проверки: один раз собирает проект и снимает перечисленные кадры. node tools/stills.mjs Reel out/st 0,60,150
import { bundle } from "@remotion/bundler";
import { renderStill, selectComposition } from "@remotion/renderer";
import fs from "fs";
import os from "os";
import path from "path";
const [comp, out, list] = process.argv.slice(2);
fs.mkdirSync(out, { recursive: true });
for (const f of fs.readdirSync(out)) if (/^f\d+\.jpg$/.test(f)) fs.rmSync(path.join(out, f));
const serveUrl = await bundle({ entryPoint: path.resolve("src/index.ts") });
const composition = await selectComposition({ serveUrl, id: comp });
for (const f of list.split(",").map(Number)) {
  await renderStill({ composition, serveUrl, frame: f, output: path.join(out, `f${String(f).padStart(4, "0")}.jpg`), imageFormat: "jpeg", jpegQuality: 90 });
  console.log("кадр", f);
}
fs.rmSync(serveUrl, { recursive: true, force: true });
for (const d of fs.readdirSync(os.tmpdir())) if (d.startsWith("remotion-webpack-bundle-")) fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true });
```

<!-- file: tools/sheet.py -->
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Лист кадров-проверок с линиями безопасной зоны Instagram (y 250, y 1470, x 950). python3 tools/sheet.py out/st out/st/sheet 6"""
import glob, os, sys
from PIL import Image, ImageDraw

d, out = sys.argv[1], sys.argv[2]; per = int(sys.argv[3]) if len(sys.argv) > 3 else 6
fs = sorted(glob.glob(os.path.join(d, "f*.jpg"))); W, H = 405, 720
for n in range(0, len(fs), per):
    row = fs[n:n + per]; sh = Image.new("RGB", ((W + 6) * len(row), H + 22), (30, 30, 30))
    for i, p in enumerate(row):
        im = Image.open(p).convert("RGB").resize((W, H)); dr = ImageDraw.Draw(im)
        for y, c in ((250, (0, 255, 255)), (1470, (255, 255, 0))): dr.line((0, y * H / 1920, W, y * H / 1920), fill=c, width=1)
        dr.line((950 * W / 1080, 0, 950 * W / 1080, H), fill=(255, 255, 0), width=1)
        sh.paste(im, (i * (W + 6), 22)); ImageDraw.Draw(sh).text((i * (W + 6) + 4, 5), os.path.basename(p)[1:5], fill=(255, 255, 255))
    sh.save(f"{out}_{n // per}.jpg", quality=85)
print("листов:", (len(fs) + per - 1) // per)
```

<!-- file: work/edit.json -->
```json
{
  "source": "ПУТЬ/К/ВИДЕО.mov",
  "transcript": "work/run1/a16.json",
  "fix": {},
  "merge": [],
  "drop": [],
  "ends": [],
  "show": [],
  "whole": [],
  "cut_pauses_over": 0,
  "grade": true,
  "out_name": "reel.mp4"
}
```

<!-- file: work/sfx.json -->
```json
[]
```

---

## 15. Чек-лист перед сдачей

- [ ] Расшифровка сверена двумя прогонами, выдуманных слов нет, имена и бренды написаны правильно.
- [ ] Первый кадр: человек на весь экран, карточка уже не пустая или пустеет не дольше 3 кадров.
- [ ] Каждый блок раскладки не короче 3,5 с.
- [ ] Субтитры только целыми фразами, не дублируют графику, не короче двух слов.
- [ ] Все слова на экране сказаны человеком; цифры и пропорции только названные им.
- [ ] Ни одна надпись не вылезает за карточку или край экрана; главное в безопасной зоне.
- [ ] Видео в раскладке `over` доходит до низа экрана.
- [ ] Палитра выбрана по теме и не совпадает с прошлым роликом; плашка субтитров в цвет карточки.
- [ ] Три удара на смысловых словах, других звуков нет.
- [ ] Громкость −14 LUFS (±0,5), пик не выше −1 dBFS, разброс до 5 LU.
- [ ] Ролик просмотрен целиком по листам кадров после рендера.
- [ ] Файл отправлен пользователю; в отчёте палитра, сцены, удары и таблица «в тексте / сказано».
