# 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// │ (FastAPI :8000) │ Zustand · PlaybackStore │ ├ project.json Remotion Player ▼ ├ assets/. CodeMirror ┌──────────────────┐ ├ previews/ proxies/ │ │ routes (~50) │ ├ remotion// │ 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.txt (этот файл) Назад: 02 — AI-агент для ресторанного склада → https://ilyadev.xyz/cases/ai-warehouse.ru.txt Дальше: 04 — Bullet Reign · Roblox → https://ilyadev.xyz/cases/roblox-game.ru.txt Индекс: https://ilyadev.xyz/llms-ru.txt — полный список кейсов Автор: Илья Казанцев — https://ilyadev.xyz/index.ru.txt