# AI-видеоредактор

`03 · ai-video-editor · R&D`

Локальный AI-видеоредактор как MCP-style substrate — file-level tool API с hash-verified diffs и audit trails. Таймлайн, сценарии и async TTS в коде, Gemini как оркестратор.

**Скоуп:** Соло · 3 недели  
**Роль:** Локальная Remotion-IDE

**Видео:** [YouTube](https://www.youtube.com/watch?v=krtWtN0wKPc) · [RuTube](https://rutube.ru/video/private/25ae4e70d1af8ad3f7391d51bd3c0731/?p=CEoR_7VKlvvMEbkE3Hl8xw)

## Разбор видео

Локальный AI-видеоредактор как substrate под agent-driven workflow — таймлайн, превью, сценарии и асинхронные задачи живут как код, агент читает, правит и откатывает их построчно через hash-verified changesets. Кастомный таймлайн с зумом от кадра до часов, четырёхпанельный CodeMirror для скриптов, async-TTS пайплайн; всё работает локально.

Локальный AI-видеоредактор — основа для работы AI-агента с видео. Таймлайн, превью, сценарии и асинхронные задачи живут как код, поэтому агент может читать, править и откатывать их построчно.

Таймлайн собран с нуля — перетаскивание, разрез, магнит, уровни зума от кадра до часов.

Текстовый редактор открывает любой markdown или JSON-файл из дерева проекта в четырёх панелях CodeMirror рядом.

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

Генерация аудио — первый async-пайплайн на этой основе. Клип показывает прогресс, пока задача выполняется.

Всё работает локально. Основа собрана — остальное наслаивается сверху.

---

## Контекст

> Таймлайн в коде — иначе агент остаётся чатом.

Программно управлять видеотаймлайном снаружи коммерческого редактора — поверхность тонкая. У Premiere Pro в ExtendScript есть импорт, экспорт и базовые операции с таймлайном, но editing-поверхность тоньше, чем нужно агенту, оркестрирующему генерацию: документация быстро редеет, и куски, на которые приходится опереться в реальном workflow, лежат вне scriptable-зоны. У DaVinci scripting чистый и хорошо документированный, но доступен только в платной редакции; остальной рынок — либо закрытый, либо прототипы. Для агента, оркестрирующего генерацию поверх таймлайна, «использовать plugin-слой существующего редактора» перестаёт быть опцией рано.

Scripted-video всё чаще — это код. Remotion-композиции — TSX. Реплики сценария и тайминги живут в markdown и JSON. Когда «редактирование» сводится к «двинуть строки и пересобрать тайминг», AI-коавтор может работать теми же инструментами, что и разработчик — прочитать срез, заменить диапазон строк, посмотреть diff, откатить — при условии, что сам редактор обращается со сценарием как с файлом, а не как с чатом. И сам таймлайн тоже должен жить в коде, иначе агенту просто некуда писать.

## Факты

| | |
|---|---|
| **Скоуп** | 21 день соло |
| **Поверхности** | Браузерный редактор + локальный FastAPI media-service · monorepo + docker-compose · работает локально |
| **Таймлайн** | Зум 0.01×–50× · magnet snap · blade · undo/redo |
| **Композиция** | Remotion 4.0 · Babel-standalone on-demand · per-clip error overlay |
| **AI-агент** | Gemini 3 + 2.5 · audit-first changesets · sha256 hash-verified apply/revert · SSE |
| **Аудио** | Async TTS-пайплайн · очередь + state machine на клипе · two-speaker dialogue · версии для сценария |
| **Статус** | Substrate собран end-to-end · script-правки и async TTS живые · agent-orchestrator и экспорт — следующие срезы |

## Архитектура

### Жизненный цикл AI-правки

```text
 1  Пользователь                  Сообщение + Enter
        │
 2  Frontend
        │  POST /ai/chat                       [threadId, model, system_instr]
        ▼
 3  media_service  (FastAPI)
        │  insert thread + message + run[pending]
        │  ThreadPoolExecutor.submit(run_chat_job)
        ▼
 4  run_chat_job  (worker thread)
        │  client.generate_stream(model, contents, tools, config)
        ▼
 5  Gemini  ──►  text  /  thought  /  functionCall
                │
                ▼
              tool_call  (file_read_slices, text_replace_lines, …)
                │
                ▼
              text_tools  ──►  audit  {before, after, meta}
        │
 6  agent_changes_v2
        │  append_file_change · sha256 base/after · unified diff
        ▼
 7  Frontend  ◄── SSE /ai/chat/{run_id}/stream      [streaming|tools|done]
        │
 8  apply_changeset(forward)
        │  verify sha256(file) == base_hash  →  write OR 409 hash_mismatch
        ▼
 9  Файл записан  ·  changeset.status = applied
```

**Audit-payload — всегда.** Каждый tool-call возвращает audit-payload (before / after / meta), даже когда apply=false. Бэк персистит before_text, after_text, sha256 base/after, unified diff и byte-size в SQLite до того, как файл будет тронут. Это сразу даёт preview-режим для ручного ревью.

**Hash-verification.** Forward-apply сравнивает текущий sha256(file) с base_hash; reverse — с after_hash. При mismatch — 409 с {path, expected, actual, direction}. force=true пропускает проверку явно, когда пользователь принимает риск перезаписи.

**SSE, не WebSocket.** Один SSE-endpoint стримит события {running, streaming, tools, retrying, complete, error}. sessionStorage хранит активный run_id, F5 переподключается без потери стрима. Cancel — отдельный POST.

### Компонентная схема

```text
   Browser                       Local services            Storage
   ───────                       ──────────────            ───────
   Frontend  (Vite · React)      media_service             /projects/<id>/
        │                        (FastAPI :8000)              │
        Zustand · PlaybackStore        │                      ├ project.json
        Remotion Player                ▼                      ├ assets/<sha>.<ext>
        CodeMirror             ┌──────────────────┐           ├ previews/  proxies/
        │                      │  routes (~50)    │           ├ remotion/<aid>/
        │  HTTPS  /api/*       │  ThreadPool(5)   │           │     manifest.json
        ├─────────────────────►│  asyncio.Sem.    │           └ scripts/
        ◄──── SSE stream ──────┤  ffmpeg subproc  │             ├ *.md  /  *.json
                               │  google-genai    │             ├ workflows/*.json
                               └────┬─────────────┘             └ Scenario_TTS.json
                                    │
                                    ▼
                          SQLite (WAL · 11 таблиц)         External
                          ────────────────────────         ────────
                          threads · messages · runs        Gemini API
                          usage_records · tool_events      google-cloud-speech
                          changesets_v2 + file_changes_v2
                          tts_jobs · stt_jobs
```

**Два сервиса, без брокера.** Frontend + media_service в одном docker-compose, плюс volume /projects. Никакого Redis или Celery — SQLite WAL + ThreadPool отыгрывают роль очереди. Подробнее в §04 / D4.

**Проект живёт на FS.** project.json — единственный source of truth по проекту. Ассеты — content-addressable (sha256 = имя файла). При рестарте media_service runtime восстанавливается из FS + SQLite, никаких прогревочных кешей не нужно сбрасывать.

**Внешнее — provider-agnostic at the call site.** Текущий substrate использует google-genai (chat + thinking + tools + TTS) и google-cloud-speech (STT) — смена модели = новый воркер, не переписка substrate. Pricing по модальностям считается локально из usage_metadata перед записью.

### Project + state model

```text
PROJECT (project.json)             RUNTIME STATE (in-memory)
──────────────────────             ─────────────────────────
Project                            Zustand store
  ├── Asset (×N)                     ├── project        (committed)
  │     · type:                      ├── editorState    (committed playhead)
  │       video|audio|image|         ├── history.past   (≤20 snapshots)
  │       remotion|text|callout      └── openTextContents
  │     · hash · originalPath
  │     · audio.{generationStage,    PlaybackStore  (мини-стор, не Zustand)
  │              progress, error}      ├── status   playing|paused|buffering
  │                                    ├── timeSeconds  (live, каждый кадр)
  ├── Folder (×N)                      ├── frame        (live)
  │                                    └── lastUpdateTs  (poller fallback)
  ├── Track (×M)
  │     ├── kind  Video | Audio
  │     └── Clip (×K)
  │           · type  Media | Text | Remotion
  │           · trackId · start · duration
  │           · linkedClipId   (V↔A пара)
  │           · transform      (overlay state)
  │
  └── EditorState
         · playhead    (committed, позиция паузы)
         · zoom · tool · selectedClipIds


SQLITE  (usage.sqlite · WAL · 11 таблиц)
─────────────────────────────────────────
threads          messages          runs               usage_records
   └── usage           └── status       └── thinking_     └── prompt/output/
       totals              run_id           preset            cached/thought

agent_changesets_v2 ──┬── agent_file_changes_v2
                      │      · seq · path
                      │      · base_hash · after_hash
                      │      · before_text · after_text · patch_text
                      │      · status  pending|ready|applied|reverted
                      │
tool_events           tts_jobs           stt_jobs
   · run_id · seq        · status           · status
   · payload_json        · result_json      · result_json
```

**Два стора, два темпа.** Zustand держит committed state (playhead-на-паузе, undo-стек, selectedClipIds). PlaybackStore — live-время. Live-обновления не доходят до Zustand — родительское дерево вокруг Remotion Player не перерисовывается со скоростью кадра.

**linkedClipId — единая модель V↔A.** Когда видео с аудио кладут на трек, импортер демультиплексит отдельный audio-asset и связывает клипы через mutual linkedClipId. Cut, move и delete каскадно работают на паре; blade чисто режет обе половины.

**Audit-trail в SQLite.** Каждый AI tool-call пишет в changesets_v2 + file_changes_v2 до того, как файл будет тронут. Колонка status (pending|ready|applied|reverted) — таймлайн правок проекта, реплеящийся forward и reverse.

## Ключевые инженерные решения

### 01 · Audit-first changesets с hash-verification

**Решение.** Каждый AI tool-call возвращает audit-payload (before / after / meta), даже при apply=false. Бэк персистит before_text, after_text, sha256 base/after, unified diff и byte-size в SQLite. apply_changeset сверяет текущий sha256(file) с base_hash и кидает 409 hash_mismatch, если файл уехал между генерацией и применением.

**Почему.** AI-агент, пишущий в файлы, — это race condition по умолчанию. Параллельная правка человеком в CodeMirror между генерацией и apply тихо клобрит одну из сторон. Hash-verification делает race видимым — UI показывает expected vs actual и кнопку force-override — а сохранённые before/after/diff заодно работают как preview-режим для промптов, которым не доверяешь сразу.

**Цена.** Каждый tool-call делает дополнительную работу (sha256 + diff + persist), даже когда патч в итоге не применится. Схема выросла на две таблицы. apply — транзакция на N файлов с rollback на первом mismatch — больше кода, чем простая запись, и больше edge-cases (force, partial apply, reverse).

### 02 · Committed playhead в Zustand, live playback — отдельный ticker

**Решение.** editorState.playhead в Zustand хранит только позицию паузы/скраба (committed). Live-время воспроизведения живёт в отдельном PlaybackStore (мини-стор, не Zustand), обновляется через onFrameUpdate каждый кадр + 200 ms-poller-fallback. PlaybackController — единственная точка интеграции с Remotion PlayerRef.

**Почему.** Наивный дизайн держит один playhead в Zustand и обновляет его каждый кадр. Это перерисовывает дерево вокруг Remotion Player 60 раз в секунду; на длинных таймлайнах под нагрузкой Player ловит периодические микро-ресинки и подёргивается. Разнести committed и live — единственный способ держать UI таймкода живым, не задевая Player.

**Цена.** Два source of truth для времени. PlaybackController обязан медиировать каждый play / pause / seek, буферизовать pendingSeek и pendingPlay до подключения player и держать fallback-poller на случай пропущенных onFrameUpdate. Подписки UI идут на кастомный subscribe-паттерн, а не на Zustand-селекторы — лишний клей поверх и без того кастомного стора.

### 03 · Babel-standalone on-demand для Remotion-клипов

**Решение.** Пользовательский Remotion-клип хранится как код в manifest.json рядом с ассетом и компилируется в рантайме через @babel/standalone (presets: react + transform-modules-commonjs). Результат оборачивается через new Function с инжекцией React и минимального Remotion API. Кеш по assetId + hash(code) + length. Compile- и runtime-ошибки ловятся и рендерятся как per-clip error overlay — не ломают остальную композицию.

**Почему.** Альтернативы — pre-compile через Vite или TS-runtime в Web Worker — обе уносят компиляцию из пути редактирования. С Babel-standalone пользователь правит код в CodeMirror, сохраняет, и следующий кадр превью отражает правки. Babel грузится отдельным lazy-чанком только при первом монтаже Remotion-клипа; для R&D-инструмента, где автор и пользователь — один человек, изоляции через new Function достаточно.

**Цена.** Babel-standalone — ощутимая зависимость, lazy-грузится с видимым индикатором "Loading Remotion compiler…" при первом использовании. PRELUDE инжектит только минимальный Remotion-набор (AbsoluteFill, Sequence, Audio, Video, Img, useCurrentFrame, useVideoConfig, interpolate, Easing, spring) — всё остальное надо добавлять явно. new Function достаточен для R&D; multi-tenant prod вынесет компиляцию в Worker.

### 04 · System prompts как живые файлы проекта

**Решение.** Системные промпты лежат в scripts/text-editor/workflows/*.json внутри дерева проекта, правятся через тот же CodeMirror, что и весь остальной код. Фронт перечитывает выбранный workflow-файл перед каждым запросом и кладёт его system_instruction в generation_config; runs.generation_config_json и usage_records.meta_json.system_prompt сохраняют {id, path, name}, чтобы каждый run помнил, какой промпт стоял.

**Почему.** Промпты — AI-side артефакты, но всё остальное в проекте уже относится к AI-артефактам как к файлам (audit-first changesets работают так же). На одной плоскости промпты версионируются с кодом, правятся через DnD-импорт, меняются без рестарта и видны в usage-аналитике рядом с моделью и стоимостью токенов. Большинство production AI-инструментов этот круг не замыкают.

**Цена.** Файл промпта перечитывается с диска на каждый запрос — нормально, пока файл маленький. Нет валидации схемы workflow JSON; битый файл падает в runtime. История промптов живёт в git плюс per-run-снапшотах; отдельного UI истории промптов нет.

### 05 · SQLite + ThreadPool как очередь, не Celery + Redis

**Решение.** AI-runs, TTS-задачи и STT-задачи живут как строки в SQLite-таблицах (runs, tts_jobs, stt_jobs) с атомарным claim_next_*_job() — SELECT, за которым следует UPDATE, выставляющий status в running. Воркеры — ThreadPoolExecutor(max_workers=5) для chat-runs и asyncio.Semaphore(10/6) для TTS/STT внутри FastAPI-event-loop. SQLite в WAL-режиме. Pending-задачи переживают рестарт media_service и пере-подхватываются при старте.

**Почему.** Дефолтный рефлекс ML-стека — Celery + Redis + Flower. Здесь продукт одно-машинный: media_service бежит рядом с фронтом в docker-compose, пиковая нагрузка — ~5 параллельных AI-runs и ~10 TTS-задач. Брокер добавил бы 2 сервиса в compose, ещё одну точку отказа и operational-вес ради нагрузки, которой нет.

**Цена.** Не масштабируется горизонтально — несколько воркер-машин на одну SQLite не натянуть. Visibility — самописная: нет Flower-дашборда, читаешь usage_records и tool_events напрямую. Фронт делает long-poll /tts/jobs/{id} до success/error вместо push-event. Миграций нет (схема пересоздаётся при старте) — добавить колонку = удалить usage.sqlite.

## Стек

| | |
|---|---|
| **Frontend** | React 18 · Vite · TypeScript · Zustand+Immer · Remotion 4 · CodeMirror · Tailwind · shadcn/ui |
| **Backend** | Python · FastAPI · pydantic · sqlite3 (raw SQL · WAL) · ffmpeg subprocess |
| **AI** | Gemini 3 (pro/flash preview) + 2.5 · google-genai 1.55 · streaming · thinking · tools · TTS · STT (google-cloud-speech) |
| **Композиция** | Remotion 4.0 · @babel/standalone (lazy) · per-clip error overlay |
| **Concurrency** | ThreadPoolExecutor(5) · asyncio.Semaphore(10/6) · ffmpeg sem (global=6, per-asset=3) · LRU-кеши |
| **Объём** | ~30К LOC TS — 3К timeline + 2.4К AI panel + 2К media + 2.3К properties (каждая поверхность — domain-rich UI) · ~8К LOC Py · 16 docs · ~50 маршрутов · 11 SQLite-таблиц |

## Уроки и статус

### Оставлю как есть

- Audit-first changesets — каждый сломанный AI-промпт откатывался одним кликом, без потери предыдущих правок. Единственная вещь, которую перепишу 1:1 в любую следующую AI-IDE.
- Committed vs live playhead split — паттерн масштабируется на любой плеер с тяжёлым родителем. Без него Remotion Player под нагрузкой ловит периодические микро-ресинки, которые не лечатся никакой мемоизацией.
- Geometry в общем utils-модуле — normalizeTransform / resolveFittedBox живут в src/utils/geometry.ts и кормят и EditorComposition, и transform-overlay. Никакого drift между preview и bbox-оверлея.
- SQLite + ThreadPool как очередь — три недели ежедневного использования на одно-машинном продукте, ни разу не пожалел об отсутствии Celery. Бесброкерная форма держит compose маленьким, а failure-modes понятными.
- Bias в сторону открытых code-поверхностей — Remotion-композиции это TSX, сценарии — markdown + JSON, workflow-промпты — JSON-файлы. Обратное к plugin-слою чужого редактора: агент правит каждый слой, потому что каждый слой с дня один лежит на клавиатуре.

### Поменял бы

- Vitest выкинул в начале — зря. Babel-on-the-fly компиляция клипов и hash-verified changesets — оба требуют unit-харнесса; lint и build не ловят регрессий ни в выводе компиляции, ни в hash-математике apply/revert. Завёл бы обратно с первого дня.
- Схема пересоздаётся при старте, миграций нет. Для соло-R&D ок (схему меняешь — удаляешь usage.sqlite); для передачи кода — реальный блокер, для возвращения к проекту через месяц — лишний раздражитель. Alembic — день; экономит каждый следующий.
- Фронт делает long-poll /tts/jobs/{id} до success/error; чат-раны едут на SSE. Два транспорта там, где хватило бы одного — переиспользование чат-SSE канала для любых background jobs с дня один сделало бы второй async-пайплайн (TTS) бесплатным, а третий (image / video) — бесплатным ещё раз.

R&D · substrate собран end-to-end · работает локально в Docker. Script-правки и async TTS живые; agent-as-orchestrator и интеграция экспорта — следующие срезы на том же audit-tracked substrate.

---

Источник: https://ilyadev.xyz/cases/ai-video-editor (HTML) · /cases/ai-video-editor.ru.md (этот файл)
Назад: 02 — AI-агент для ресторанного склада → https://ilyadev.xyz/cases/ai-warehouse.ru.md
Дальше: 04 — Bullet Reign · Roblox → https://ilyadev.xyz/cases/roblox-game.ru.md
Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов
Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.md
