From b20a68d730706b83bc1ee1cc1f282e909a9034e9 Mon Sep 17 00:00:00 2001
From: mitimaicode <309470861+mitimaicode@users.noreply.github.com>
Date: Mon, 21 Sep 2026 17:11:43 +0000
Subject: [PATCH 1/2] feat: publish Linux server edition
---
.github/workflows/ci.yml | 63 +-
.github/workflows/release.yml | 26 +-
CONTRIBUTING.md | 3 +
INSTALL.md | 97 +-
README.md | 75 +-
RELEASE_NOTES_v0.5.0-beta.md | 46 +
Resources/Info.plist | 4 +-
scripts/package_server_release.sh | 32 +
server/linux/PROVENANCE.md | 36 +
server/linux/README.md | 112 ++
server/linux/VERSION | 1 +
server/linux/healthcheck.py | 26 +
server/linux/install.sh | 157 +++
server/linux/openclaw-plugin/README.md | 29 +
server/linux/openclaw-plugin/index.js | 617 +++++++++++
server/linux/openclaw-plugin/index.test.js | 127 +++
server/linux/openclaw-plugin/job-isolation.js | 59 ++
.../openclaw-plugin/openclaw.plugin.json | 38 +
.../linux/openclaw-plugin/package-lock.json | 24 +
server/linux/openclaw-plugin/package.json | 17 +
.../openclaw-plugin/publication-completion.js | 74 ++
.../openclaw-plugin/source-validation.js | 49 +
.../openclaw-plugin/source-validation.test.js | 35 +
.../linux/openclaw-plugin/telegram-origin.js | 57 +
server/linux/openclaw-plugin/tool-result.js | 7 +
.../linux/openclaw-plugin/tool-result.test.js | 11 +
server/linux/openclaw-plugin/worker-scope.js | 54 +
.../openclaw-plugin/worker-scope.test.js | 53 +
server/linux/requirements-stt.txt | 9 +
server/linux/requirements-video.txt | 11 +
server/linux/stt/server.py | 362 +++++++
server/linux/stt/transcribe.py | 82 ++
server/linux/systemd/mitim-stt.service.in | 19 +
server/linux/tests/test_enrich_ollama.py | 136 +++
server/linux/tests/test_release_contract.py | 62 ++
.../linux/tests/test_telegram_publication.py | 207 ++++
server/linux/video/align_qwen.py | 148 +++
server/linux/video/diarize_pyannote.py | 147 +++
server/linux/video/enrich_ollama.py | 621 +++++++++++
server/linux/video/telegram_publication.py | 481 +++++++++
server/linux/video/transcribe_mitim_stt.py | 292 +++++
server/linux/video/video_pipeline.py | 993 ++++++++++++++++++
42 files changed, 5461 insertions(+), 38 deletions(-)
create mode 100644 RELEASE_NOTES_v0.5.0-beta.md
create mode 100755 scripts/package_server_release.sh
create mode 100644 server/linux/PROVENANCE.md
create mode 100644 server/linux/README.md
create mode 100644 server/linux/VERSION
create mode 100755 server/linux/healthcheck.py
create mode 100755 server/linux/install.sh
create mode 100644 server/linux/openclaw-plugin/README.md
create mode 100644 server/linux/openclaw-plugin/index.js
create mode 100644 server/linux/openclaw-plugin/index.test.js
create mode 100644 server/linux/openclaw-plugin/job-isolation.js
create mode 100644 server/linux/openclaw-plugin/openclaw.plugin.json
create mode 100644 server/linux/openclaw-plugin/package-lock.json
create mode 100644 server/linux/openclaw-plugin/package.json
create mode 100644 server/linux/openclaw-plugin/publication-completion.js
create mode 100644 server/linux/openclaw-plugin/source-validation.js
create mode 100644 server/linux/openclaw-plugin/source-validation.test.js
create mode 100644 server/linux/openclaw-plugin/telegram-origin.js
create mode 100644 server/linux/openclaw-plugin/tool-result.js
create mode 100644 server/linux/openclaw-plugin/tool-result.test.js
create mode 100644 server/linux/openclaw-plugin/worker-scope.js
create mode 100644 server/linux/openclaw-plugin/worker-scope.test.js
create mode 100644 server/linux/requirements-stt.txt
create mode 100644 server/linux/requirements-video.txt
create mode 100644 server/linux/stt/server.py
create mode 100644 server/linux/stt/transcribe.py
create mode 100644 server/linux/systemd/mitim-stt.service.in
create mode 100644 server/linux/tests/test_enrich_ollama.py
create mode 100644 server/linux/tests/test_release_contract.py
create mode 100644 server/linux/tests/test_telegram_publication.py
create mode 100644 server/linux/video/align_qwen.py
create mode 100644 server/linux/video/diarize_pyannote.py
create mode 100644 server/linux/video/enrich_ollama.py
create mode 100644 server/linux/video/telegram_publication.py
create mode 100644 server/linux/video/transcribe_mitim_stt.py
create mode 100644 server/linux/video/video_pipeline.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b2c6c07..a40420b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,20 +73,71 @@ jobs:
- name: Package pull request test build
if: github.event_name == 'pull_request'
run: |
+ version=$(tr -d '[:space:]' < server/linux/VERSION)
ditto -c -k --sequesterRsrc --keepParent \
"$RUNNER_TEMP/VoiceSwitch/VoiceSwitch.app" \
- "$RUNNER_TEMP/VoiceSwitch-v0.4.0-beta-test.zip"
+ "$RUNNER_TEMP/VoiceSwitch-${version}-test.zip"
cd "$RUNNER_TEMP"
- shasum -a 256 VoiceSwitch-v0.4.0-beta-test.zip \
- > VoiceSwitch-v0.4.0-beta-test.zip.sha256
+ shasum -a 256 "VoiceSwitch-${version}-test.zip" \
+ > "VoiceSwitch-${version}-test.zip.sha256"
- name: Upload pull request test build
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
- name: VoiceSwitch-v0.4.0-beta-macos-arm64-test
+ name: VoiceSwitch-${{ github.event.pull_request.number }}-macos-arm64-test
path: |
- ${{ runner.temp }}/VoiceSwitch-v0.4.0-beta-test.zip
- ${{ runner.temp }}/VoiceSwitch-v0.4.0-beta-test.zip.sha256
+ ${{ runner.temp }}/VoiceSwitch-*-test.zip
+ ${{ runner.temp }}/VoiceSwitch-*-test.zip.sha256
+ if-no-files-found: error
+ retention-days: 7
+
+ server:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Validate Linux Server Edition
+ run: |
+ bash -n server/linux/install.sh scripts/package_server_release.sh
+ python3 -m py_compile \
+ server/linux/stt/*.py \
+ server/linux/video/*.py \
+ server/linux/healthcheck.py
+ PYTHONPATH=server/linux/video \
+ python3 -m unittest discover -s server/linux/tests -v
+ VOICESWITCH_SETUP_VALIDATE_ONLY=1 \
+ server/linux/install.sh --core-only --no-start
+
+ - name: Validate optional OpenClaw plugin
+ working-directory: server/linux/openclaw-plugin
+ run: |
+ npm ci --omit=dev --omit=peer --ignore-scripts
+ node --check index.js
+ node --test \
+ index.test.js \
+ source-validation.test.js \
+ tool-result.test.js \
+ worker-scope.test.js
+
+ - name: Package Linux Server Edition
+ run: |
+ chmod +x server/linux/install.sh server/linux/healthcheck.py scripts/package_server_release.sh
+ ./scripts/package_server_release.sh
+ archive="dist/VoiceSwitch-Server-$(tr -d '[:space:]' < server/linux/VERSION)-linux-x86_64.tar.gz"
+ test -f "$archive"
+ test -f "$archive.sha256"
+ tar -tzf "$archive" | grep -q '/install.sh$'
+
+ - name: Upload Linux Server test build
+ if: github.event_name == 'pull_request'
+ uses: actions/upload-artifact@v7
+ with:
+ name: VoiceSwitch-${{ github.event.pull_request.number }}-linux-x86_64-test
+ path: |
+ dist/VoiceSwitch-Server-*.tar.gz
+ dist/VoiceSwitch-Server-*.tar.gz.sha256
if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index afaf979..36fee32 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,11 +19,27 @@ jobs:
- name: Validate release source
run: |
zsh -n scripts/*.sh Resources/install_runtime.sh
- python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_worker.py
+ bash -n server/linux/install.sh scripts/package_server_release.sh
+ python3 -m py_compile \
+ worker/asr_worker.py \
+ worker/media_worker.py \
+ worker/text_worker.py \
+ server/linux/stt/*.py \
+ server/linux/video/*.py \
+ server/linux/healthcheck.py
python3 -m unittest discover -s tests -v
+ PYTHONPATH=server/linux/video \
+ python3 -m unittest discover -s server/linux/tests -v
+ node --check server/linux/openclaw-plugin/index.js
+ node --test \
+ server/linux/openclaw-plugin/index.test.js \
+ server/linux/openclaw-plugin/source-validation.test.js \
+ server/linux/openclaw-plugin/tool-result.test.js \
+ server/linux/openclaw-plugin/worker-scope.test.js
bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)
tag_version="${GITHUB_REF_NAME#v}"
test "$bundle_version" = "${tag_version%%-*}"
+ test "$(tr -d '[:space:]' < server/linux/VERSION)" = "$tag_version"
- name: Validate clean GigaAM dependency install
run: |
@@ -37,9 +53,11 @@ jobs:
- name: Build release archive
run: |
- chmod +x scripts/*.sh Resources/install_runtime.sh
+ chmod +x scripts/*.sh Resources/install_runtime.sh server/linux/install.sh server/linux/healthcheck.py
./scripts/package_release.sh "${GITHUB_REF_NAME#v}"
+ ./scripts/package_server_release.sh "${GITHUB_REF_NAME#v}"
test -f "dist/VoiceSwitch-${GITHUB_REF_NAME#v}-macos-arm64/VoiceSwitch.app/Contents/Resources/media_worker.py"
+ test -f "dist/VoiceSwitch-Server-${GITHUB_REF_NAME#v}-linux-x86_64.tar.gz"
- name: Publish GitHub release
env:
@@ -52,7 +70,9 @@ jobs:
dist/VoiceSwitch-*.dmg.sha256
dist/VoiceSwitch-*.zip
dist/VoiceSwitch-*.zip.sha256
- --title "VoiceSwitch ${GITHUB_REF_NAME}"
+ dist/VoiceSwitch-Server-*.tar.gz
+ dist/VoiceSwitch-Server-*.tar.gz.sha256
+ --title "VoiceSwitch ${GITHUB_REF_NAME} — Linux Server Edition"
)
if [[ -f "$notes_file" ]]; then
args+=(--notes-file "$notes_file")
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bcbc8fa..1593548 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,6 +19,9 @@ swift build
zsh -n scripts/*.sh Resources/install_runtime.sh
python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_worker.py scripts/collect_github_stats.py
python3 -m unittest discover -s tests -v
+bash -n server/linux/install.sh scripts/package_server_release.sh
+PYTHONPATH=server/linux/video python3 -m unittest discover -s server/linux/tests -v
+(cd server/linux/openclaw-plugin && npm ci --omit=dev --omit=peer --ignore-scripts && node --test index.test.js source-validation.test.js tool-result.test.js worker-scope.test.js)
```
Для изменения распознавания желательно приложить обезличенный набор тестовых
diff --git a/INSTALL.md b/INSTALL.md
index 6c99db4..3af0a1e 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -1,8 +1,87 @@
# Установка VoiceSwitch
-## Готовый beta-релиз
+В релизе есть два независимых пакета: Linux Server Edition и приложение для
+Mac. DMG не предназначен для Linux, а серверный архив не содержит SwiftUI GUI.
-### 1. Скачивание
+## Linux Server Edition
+
+### Требования Linux
+
+- Linux x86_64 и NVIDIA GPU с рабочим драйвером;
+- Python 3.12, user systemd и системный `ffprobe`;
+- место для двух Python-окружений и загружаемых моделей;
+- для pyannote diarization — принятие условий модели и локальная авторизация
+ Hugging Face;
+- для summary и анализа кадров — локальный Ollama с `qwen3-vl:8b`.
+
+### Установка Linux
+
+1. На странице [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases)
+ скачайте `VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz` и соседний
+ файл `.sha256`.
+2. Проверьте архив и распакуйте его:
+
+```bash
+sha256sum -c VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz.sha256
+tar -xzf VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz
+cd VoiceSwitch-Server-0.5.0-beta-linux-x86_64
+```
+
+3. Запустите установщик без `sudo`:
+
+```bash
+./install.sh
+```
+
+Он создаёт отдельные окружения в `~/.local/share/mitim-stt` и
+`~/.local/share/mitim-video`, user service `mitim-stt.service` и команду
+`~/.local/bin/voiceswitch-video`. Системный Python и shell-профиль не
+изменяются.
+
+Только resident STT без длинного видеоконвейера:
+
+```bash
+./install.sh --core-only
+```
+
+Полный контур вместе с OpenClaw TaskFlow plugin:
+
+```bash
+./install.sh --with-openclaw
+```
+
+### Проверка Linux
+
+Проверка сервиса:
+
+```bash
+python3 healthcheck.py
+```
+
+Короткое аудио:
+
+```bash
+~/.local/share/mitim-stt/transcribe.py /absolute/path/to/audio.ogg
+```
+
+Длинное видео:
+
+```bash
+~/.local/bin/voiceswitch-video /absolute/path/to/video.mp4 --profile standard
+```
+
+Сервис слушает только `127.0.0.1:18790`. Не публикуйте этот порт наружу:
+endpoint принимает путь к локальному файлу и предназначен для доверенных
+процессов того же пользователя.
+
+Полное устройство пакета, профили и закреплённые версии описаны в
+[`server/linux/README.md`](server/linux/README.md).
+
+## Приложение для macOS
+
+### Готовый beta-релиз
+
+#### 1. Скачивание
Откройте [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases)
и скачайте файл с окончанием `macos-arm64.dmg` из самого нового релиза.
@@ -15,7 +94,7 @@ DMG — рекомендуемый и самый простой вариант.
shasum -a 256 VoiceSwitch-*.dmg
```
-### 2. Копирование в «Программы»
+#### 2. Копирование в «Программы»
1. Дважды нажмите скачанный DMG.
2. Перетащите `VoiceSwitch.app` на ярлык **«Программы»** внутри открывшегося
@@ -25,7 +104,7 @@ shasum -a 256 VoiceSwitch-*.dmg
Не запускайте VoiceSwitch прямо из DMG или папки «Загрузки»: путь приложения
важен для разрешения автоматической вставки.
-### 3. Первый запуск неподписанной beta
+#### 3. Первый запуск неподписанной beta
Публичная версия подписана ad-hoc и не нотарифицирована Apple, потому что
проект не использует платную подписку Apple Developer.
@@ -39,7 +118,7 @@ shasum -a 256 VoiceSwitch-*.dmg
не удалось проверить разработчика. В этом случае закройте предупреждение и
используйте правый клик → **Открыть**.
-### 4. Выбор моделей
+#### 4. Выбор моделей
Встроенный мастер предлагает три стартовых комплекта:
@@ -55,7 +134,7 @@ Python 3.12, Python-зависимости, ffmpeg и только выбран
Администраторский пароль не требуется; системный Python и shell-профиль не
изменяются.
-### 5. Разрешения macOS
+#### 5. Разрешения macOS
Мастер последовательно попросит:
@@ -68,7 +147,7 @@ Python 3.12, Python-зависимости, ffmpeg и только выбран
включите переключатель. macOS может запросить Touch ID или пароль владельца
Mac. Вернитесь в VoiceSwitch и нажмите **Проверить снова**.
-### 6. Первая проверка
+#### 6. Первая проверка
1. Откройте любое поле ввода и поставьте в него курсор.
2. Нажмите `fn + Option` — появится красный индикатор записи.
@@ -80,7 +159,7 @@ Mac. Вернитесь в VoiceSwitch и нажмите **Проверить с
буфере обмена, поэтому при проблеме его можно вставить вручную через
`Command + V`.
-### 7. Проверка аудио или видео
+#### 7. Проверка аудио или видео
1. В меню VoiceSwitch переключите **Диктовка** на **Файл**.
2. Оставьте GigaAM либо выберите установленный Whisper/Qwen.
@@ -96,7 +175,7 @@ Application Support. Кадры видео не анализируются. Ме
Повторно выберите тот же неизменённый файл, тот же движок и контекст, чтобы
продолжить. Apple SpeechAnalyzer в файловом режиме пока недоступен.
-### Если загрузка моделей прервалась
+#### Если загрузка моделей прервалась
Не удаляйте папку Runtime. Нажмите **Продолжить установку** в меню
VoiceSwitch: установщик делает до трёх попыток для загружаемых компонентов,
diff --git a/README.md b/README.md
index 0250ee1..ef636df 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,28 @@
# VoiceSwitch
-
+
-> Локальная диктовка и расшифровка аудио/видео для macOS.
+> Локальная диктовка и расшифровка аудио/видео для macOS и Linux-сервера.
[](https://github.com/mitimaicode/VoiceSwitch/actions/workflows/ci.yml)
[](https://github.com/mitimaicode/VoiceSwitch/releases)
[](https://github.com/mitimaicode/VoiceSwitch/releases)
[](LICENSE)
-VoiceSwitch записывает речь по глобальной горячей клавише, распознаёт её
-полностью локально и вставляет текст в активное приложение. Отдельный режим
-«Файл» расшифровывает аудиодорожку локального аудио или видео, умеет продолжить
-прерванную задачу и сохраняет TXT, SRT, VTT и JSON.
+В репозитории две редакции. Приложение для macOS записывает речь по глобальной
+горячей клавише, распознаёт её локально и вставляет текст в активное приложение.
+Linux Server Edition воспроизводит установленный серверный контур для короткой
+речи и длинных видео: resident STT, checkpoint/resume, точное выравнивание,
+диаризация, визуальный анализ и несколько форматов экспорта.
+
+| Редакция | Платформа | Пакет релиза |
+|---|---|---|
+| VoiceSwitch for Mac | Apple Silicon, macOS 14+ | `VoiceSwitch-…-macos-arm64.dmg` |
+| VoiceSwitch Server Edition | Linux x86_64, NVIDIA CUDA | `VoiceSwitch-Server-…-linux-x86_64.tar.gz` |
+
+## Приложение для macOS
@@ -39,12 +47,38 @@ VoiceSwitch записывает речь по глобальной горяче
- аудио и текст не отправляются во внешние API;
- журнал сравнения помогает выбрать модель под собственную речь.
+## Linux Server Edition
+
+Серверный пакет — это не запуск DMG на Linux. Он содержит отдельный контур,
+снятый с реально работающей серверной установки:
+
+- GigaAM v3 E2E RNNT на CUDA и Whisper Turbo fallback;
+- loopback HTTP-сервис `127.0.0.1:18790` под user systemd;
+- длинные локальные файлы, Telegram media и YouTube;
+- профили `quick`, `standard`, `interview`, `multilingual` и `deep`;
+- Qwen3 ForcedAligner, pyannote diarization и локальный Ollama `qwen3-vl:8b`;
+- Markdown, JSON, SRT, VTT, QC, manifest и возобновляемые checkpoint;
+- необязательный OpenClaw TaskFlow plugin со start/resume/status/cancel.
+
+Краткая установка:
+
+```bash
+tar -xzf VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz
+cd VoiceSwitch-Server-0.5.0-beta-linux-x86_64
+./install.sh
+python3 healthcheck.py
+```
+
+Подробности, требования и варианты установки находятся в
+[`server/linux/README.md`](server/linux/README.md) и [INSTALL.md](INSTALL.md).
+Порт `18790` намеренно доступен только локально; публиковать его в сеть нельзя.
+
> [!IMPORTANT]
-> Это beta-релиз для Mac с Apple Silicon. Приложение пока подписано ad-hoc и
-> не нотарифицировано Apple. Подробно о различиях и плане выпуска:
+> Обе редакции пока beta. Приложение для Mac подписано ad-hoc и не
+> нотарифицировано Apple. Подробно о различиях и плане выпуска:
> [подпись и нотарификация](docs/SIGNING_AND_NOTARIZATION.md).
-## Системные требования
+## Системные требования macOS
- Mac с Apple Silicon (`M1` или новее);
- macOS 14 Sonoma или новее;
@@ -52,7 +86,7 @@ VoiceSwitch записывает речь по глобальной горяче
- около 2,5 ГБ для рекомендуемой установки с GigaAM или около 10 ГБ для всех моделей;
- интернет только во время первоначальной установки моделей.
-## Установка
+## Установка macOS
1. Откройте [GitHub Releases](https://github.com/mitimaicode/VoiceSwitch/releases),
выберите самый новый релиз и скачайте `VoiceSwitch-…-macos-arm64.dmg`.
@@ -218,7 +252,8 @@ chmod +x scripts/*.sh Resources/install_runtime.sh
Создание компактного release-архива без весов моделей:
```zsh
-./scripts/package_release.sh 0.4.0-beta
+./scripts/package_release.sh 0.5.0-beta
+./scripts/package_server_release.sh 0.5.0-beta
```
Публичный release-скрипт по умолчанию использует ad-hoc подпись. Для стабильной
@@ -247,6 +282,10 @@ identity через `VOICESWITCH_CODESIGN_IDENTITY`.
- [OpenAI Whisper](https://github.com/openai/whisper) — MIT;
- [MLX Whisper](https://github.com/ml-explore/mlx-examples/tree/main/whisper);
- [Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR) — Apache-2.0;
+- [Qwen3 ForcedAligner](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B) —
+ условия модели опубликованы её автором;
+- [pyannote.audio](https://github.com/pyannote/pyannote-audio) — MIT, отдельная
+ модель diarization требует принятия условий на Hugging Face;
- [mlx-qwen3-asr](https://github.com/moona3k/mlx-qwen3-asr) — Apache-2.0;
- [Qwen3-4B](https://huggingface.co/Qwen/Qwen3-4B-MLX-4bit) — Apache-2.0;
- [MLX LM](https://github.com/ml-explore/mlx-lm) — MIT;
@@ -260,13 +299,13 @@ VoiceSwitch не связан с авторами перечисленных п
English summary
-VoiceSwitch is a local macOS dictation and media-transcription menu-bar app for
-Apple Silicon. It switches between GigaAM, Whisper Large V3 Turbo, Qwen3-ASR
-1.7B, and Apple SpeechAnalyzer. Press `fn + Option` to dictate, or import a
-local audio/video file and export TXT, SRT, VTT, and JSON. Video import
-transcribes the audio track only; subtitle timestamps are coarse chunk
-boundaries. Audio and transcripts stay on the Mac. Apple SpeechAnalyzer
-requires macOS 26 or newer and is currently limited to dictation mode.
+VoiceSwitch provides a local macOS dictation app and a separate Linux Server
+Edition. The Mac app supports GigaAM, Whisper, Qwen3-ASR and Apple
+SpeechAnalyzer. The Linux package reproduces the project's NVIDIA/CUDA server
+stack for resident speech recognition and resumable long-video processing,
+including optional alignment, diarization, visual analysis and OpenClaw task
+orchestration. Media and transcripts stay on the machine unless a user
+explicitly uses the YouTube download or Telegram publication integrations.
See [INSTALL.md](INSTALL.md) for installation details and use GitHub Issues or
Discussions for feedback.
diff --git a/RELEASE_NOTES_v0.5.0-beta.md b/RELEASE_NOTES_v0.5.0-beta.md
new file mode 100644
index 0000000..ded7f5d
--- /dev/null
+++ b/RELEASE_NOTES_v0.5.0-beta.md
@@ -0,0 +1,46 @@
+# VoiceSwitch v0.5.0-beta — Linux Server Edition
+
+Этот релиз публикует воспроизводимый пакет той версии локального распознавания
+речи и видео, которая фактически работает на Linux-сервере владельца проекта.
+macOS-приложение остаётся в релизе, но главное изменение v0.5 — отдельный архив
+`VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz`.
+
+## Linux Server Edition
+
+- resident GigaAM v3 E2E RNNT на CUDA;
+- Whisper Turbo как явный режим и fallback;
+- loopback HTTP endpoint `127.0.0.1:18790`;
+- user-systemd service без root;
+- длинные локальные файлы, Telegram media и YouTube;
+- минутные блоки, checkpoint и resume;
+- TXT, Markdown, JSON, SRT и VTT;
+- Qwen3 ForcedAligner;
+- pyannote diarization;
+- локальный визуальный enrichment через Ollama `qwen3-vl:8b`;
+- OpenClaw TaskFlow plugin `0.1.6` с start/resume/status/cancel.
+
+Установленные серверные версии PyTorch, GigaAM, Whisper, Qwen ASR и pyannote
+закреплены в requirements. В `PROVENANCE.md` опубликованы SHA-256 исходных
+файлов серверного снимка и перечислены санитарные изменения перед публикацией.
+
+## Установка Linux
+
+1. Скачайте `VoiceSwitch-Server-0.5.0-beta-linux-x86_64.tar.gz` и файл
+ `.sha256`.
+2. Проверьте SHA-256 и распакуйте архив.
+3. Внутри каталога запустите `./install.sh`.
+
+Требуются Linux x86_64, NVIDIA GPU, Python 3.12, user systemd и `ffprobe`.
+Модели и зависимости загружаются локально при установке и первом запуске.
+
+## Безопасность и ограничения
+
+- HTTP endpoint доступен только с localhost и не должен публиковаться наружу.
+- В архив не входят модели, cache, медиа, расшифровки, токены, cookies,
+ Telegram chat ID или серверные журналы.
+- Diarization требует отдельно принять условия модели pyannote и выполнить
+ локальную авторизацию Hugging Face.
+- Визуальный enrichment требует отдельно установленный Ollama.
+
+Подробная инструкция находится в `server/linux/README.md` внутри исходников и
+релизного архива.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 521a576..093ec89 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.4.0
+ 0.5.0
CFBundleVersion
- 13
+ 14
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/scripts/package_server_release.sh b/scripts/package_server_release.sh
new file mode 100755
index 0000000..9eb96f4
--- /dev/null
+++ b/scripts/package_server_release.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERSION="${1:-$(tr -d '[:space:]' < "$ROOT/server/linux/VERSION")}"
+OUTPUT_ROOT="$ROOT/dist"
+ARCHIVE_NAME="VoiceSwitch-Server-${VERSION}-linux-x86_64"
+TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/voiceswitch-server-package.XXXXXX")"
+
+cleanup() {
+ rm -rf "$TEMP_ROOT"
+}
+trap cleanup EXIT
+
+mkdir -p "$OUTPUT_ROOT" "$TEMP_ROOT/$ARCHIVE_NAME"
+cp -R "$ROOT/server/linux/." "$TEMP_ROOT/$ARCHIVE_NAME/"
+find "$TEMP_ROOT/$ARCHIVE_NAME" -type d -name __pycache__ -prune -exec rm -rf {} +
+chmod 0755 \
+ "$TEMP_ROOT/$ARCHIVE_NAME/install.sh" \
+ "$TEMP_ROOT/$ARCHIVE_NAME/healthcheck.py"
+
+tar -C "$TEMP_ROOT" -czf "$OUTPUT_ROOT/$ARCHIVE_NAME.tar.gz" "$ARCHIVE_NAME"
+(
+ cd "$OUTPUT_ROOT"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$ARCHIVE_NAME.tar.gz" > "$ARCHIVE_NAME.tar.gz.sha256"
+ else
+ shasum -a 256 "$ARCHIVE_NAME.tar.gz" > "$ARCHIVE_NAME.tar.gz.sha256"
+ fi
+)
+
+echo "$OUTPUT_ROOT/$ARCHIVE_NAME.tar.gz"
diff --git a/server/linux/PROVENANCE.md b/server/linux/PROVENANCE.md
new file mode 100644
index 0000000..ff861c1
--- /dev/null
+++ b/server/linux/PROVENANCE.md
@@ -0,0 +1,36 @@
+# Provenance: installed Linux server snapshot
+
+Снимок снят 21 сентября 2026 года с фактически установленного серверного
+контура. Модели, cache, пользовательские медиа, результаты, токены и журналы в
+релиз не входят.
+
+## Resident STT
+
+| Файл | SHA-256 установленного источника |
+|---|---|
+| `stt/server.py` | `2a7ffdde45929a7f3151620c9795d6a990e88e442d78068ccdaddfee734c3c3e` |
+| `stt/transcribe.py` | `8340c4452a89c003c18453d9792ca8f61a27af3247af863bd2518ffdba10ce92` |
+| systemd unit | `89d98f730650b7927132520527ce191c783481f4f83257fb33ede8ef153b9436` |
+
+В публичном `transcribe.py` изменён только комментарий с абсолютным домашним
+путём. Unit представлен переносимым шаблоном с теми же параметрами запуска.
+
+## Long-video pipeline
+
+| Файл | SHA-256 установленного источника |
+|---|---|
+| `video/video_pipeline.py` | `0c44b359f4453288048575bd442fc49c497113bfca03fb888629df0679893443` |
+| `video/align_qwen.py` | `4390900317e7f98c929497ac075a7af01fd72b1d6f506ac9211bef86f3155f49` |
+| `video/diarize_pyannote.py` | `c6af00ab0d74051e94ed37af21122118c52ed73cfa02055bb591bc2f75aa5977` |
+| `video/enrich_ollama.py` | `8cca0137d2411bfdc3505768f7ad89bb347fb2a1a45e416644af8ee84fa3d139` |
+
+В Telegram outbox изменено только имя подключения по умолчанию. Реальный chat
+ID из внутренних примеров заменён фиктивным. OpenClaw plugin `0.1.6` сохраняет
+логику установленной версии, но абсолютные пути заменены путями от домашнего
+каталога и переменными окружения.
+
+## Подтверждение работы
+
+На момент снимка `mitim-stt.service` был `active (running)` более трёх недель.
+Журнал содержал успешные ответы `/transcribe` за 21 сентября 2026 года.
+HTTP endpoint намеренно loopback-only.
diff --git a/server/linux/README.md b/server/linux/README.md
new file mode 100644
index 0000000..b70bda3
--- /dev/null
+++ b/server/linux/README.md
@@ -0,0 +1,112 @@
+# VoiceSwitch Server Edition for Linux
+
+Этот пакет воспроизводит локальный контур распознавания, установленный на
+сервере владельца VoiceSwitch. Это отдельная Linux-версия, а не запуск
+SwiftUI-приложения или DMG на сервере.
+
+## Что входит
+
+- resident HTTP STT на `127.0.0.1:18790`;
+- GigaAM v3 E2E RNNT на CUDA как основной русский движок;
+- Whisper Turbo как явный режим и fallback;
+- локальная нормализация аудио через ffmpeg;
+- длинные аудио и видео минутными блоками с checkpoint/resume;
+- TXT, Markdown, JSON, SRT и VTT;
+- профили `quick`, `standard`, `interview`, `multilingual` и `deep`;
+- Qwen3 ForcedAligner, pyannote diarization и визуальный enrichment через
+ локальный Ollama `qwen3-vl:8b`;
+- необязательный OpenClaw TaskFlow plugin `0.1.6` с start/resume/status/cancel.
+
+Исходные медиа, расшифровки и запросы не отправляются во внешние ASR API.
+Первоначальная установка загружает Python-пакеты и модели из их официальных
+репозиториев. YouTube-режим обращается к YouTube через `yt-dlp`.
+
+## Требования
+
+- Linux x86_64;
+- NVIDIA GPU и рабочий драйвер;
+- Python 3.12 и user systemd;
+- `ffprobe` из системного пакета ffmpeg;
+- свободное место для двух Python-окружений и моделей;
+- для diarization — принятие условий модели pyannote и локальная авторизация
+ Hugging Face (`HF_TOKEN` или `hf auth login`);
+- для визуального enrichment — локальный Ollama и модель `qwen3-vl:8b`.
+
+Сервис привязан только к loopback. Не публикуйте порт `18790` наружу: endpoint
+принимает абсолютный путь к локальному файлу и рассчитан на доверенные процессы
+того же пользователя.
+
+## Установка полного серверного контура
+
+Распакуйте архив `VoiceSwitch-Server-…-linux-x86_64.tar.gz`, перейдите в его
+каталог и выполните:
+
+```bash
+./install.sh
+```
+
+Установщик не требует root и размещает данные в:
+
+- `~/.local/share/mitim-stt` — resident STT;
+- `~/.local/share/mitim-video` — длинные видео, alignment и diarization;
+- `~/.config/systemd/user/mitim-stt.service` — user service;
+- `~/.local/bin/voiceswitch-video` — CLI длинного конвейера.
+
+Только короткая речь без видеоконвейера:
+
+```bash
+./install.sh --core-only
+```
+
+Добавить установленный на исходном сервере OpenClaw TaskFlow plugin:
+
+```bash
+./install.sh --with-openclaw
+```
+
+## Проверка
+
+```bash
+python3 healthcheck.py
+```
+
+Короткое аудио:
+
+```bash
+~/.local/share/mitim-stt/transcribe.py /absolute/path/to/audio.ogg
+```
+
+Длинное локальное видео:
+
+```bash
+~/.local/bin/voiceswitch-video /absolute/path/to/video.mp4 --profile standard
+```
+
+YouTube:
+
+```bash
+~/.local/bin/voiceswitch-video 'https://youtu.be/VIDEO_ID' --profile standard
+```
+
+Результаты по умолчанию сохраняются в
+`~/.local/share/mitim-video/pipeline/knowledge/videos/`.
+
+## Закреплённые версии
+
+Resident STT повторяет установленное окружение:
+
+- `torch 2.6.0+cu124`;
+- `torchaudio 2.6.0+cu124`;
+- `gigaam 0.2.0`;
+- `openai-whisper 20250625`;
+- `imageio-ffmpeg 0.6.0`.
+
+Видео-окружение:
+
+- `torch 2.8.0+cu128`;
+- `torchaudio 2.8.0+cu128`;
+- `qwen-asr 0.0.6`;
+- `pyannote.audio 4.0.7`;
+- `yt-dlp 2026.8.19`.
+
+Полные доказательства происхождения файлов перечислены в `PROVENANCE.md`.
diff --git a/server/linux/VERSION b/server/linux/VERSION
new file mode 100644
index 0000000..3d67fbf
--- /dev/null
+++ b/server/linux/VERSION
@@ -0,0 +1 @@
+0.5.0-beta
diff --git a/server/linux/healthcheck.py b/server/linux/healthcheck.py
new file mode 100755
index 0000000..47b4aa2
--- /dev/null
+++ b/server/linux/healthcheck.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sys
+import urllib.error
+import urllib.request
+
+
+def main() -> int:
+ url = "http://127.0.0.1:18790/health"
+ try:
+ with urllib.request.urlopen(url, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as error:
+ print(f"VoiceSwitch Server health check failed: {error}", file=sys.stderr)
+ return 1
+ if not payload.get("ok") or not payload.get("ready"):
+ print(json.dumps(payload, ensure_ascii=False), file=sys.stderr)
+ return 1
+ print(json.dumps(payload, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/install.sh b/server/linux/install.sh
new file mode 100755
index 0000000..ea3ddf6
--- /dev/null
+++ b/server/linux/install.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+STT_ROOT="${VOICESWITCH_STT_ROOT:-${HOME}/.local/share/mitim-stt}"
+VIDEO_ROOT="${VOICESWITCH_VIDEO_ROOT:-${HOME}/.local/share/mitim-video}"
+CACHE_ROOT="${VOICESWITCH_CACHE_ROOT:-${HOME}/.cache/mitim-stt}"
+UNIT_ROOT="${HOME}/.config/systemd/user"
+BIN_ROOT="${HOME}/.local/bin"
+PYTHON="${VOICESWITCH_PYTHON:-python3.12}"
+INSTALL_VIDEO=1
+INSTALL_OPENCLAW=0
+START_SERVICE=1
+
+usage() {
+ cat <<'EOF'
+Usage: ./install.sh [--core-only] [--with-openclaw] [--no-start]
+
+ --core-only Install resident GigaAM/Whisper STT without the long-video tools.
+ --with-openclaw Also install the optional OpenClaw TaskFlow plugin.
+ --no-start Install files but do not enable or start the user systemd service.
+
+Environment overrides:
+ VOICESWITCH_PYTHON, VOICESWITCH_STT_ROOT, VOICESWITCH_VIDEO_ROOT,
+ VOICESWITCH_CACHE_ROOT, VOICESWITCH_SETUP_VALIDATE_ONLY=1
+EOF
+}
+
+while (($#)); do
+ case "$1" in
+ --core-only) INSTALL_VIDEO=0 ;;
+ --with-openclaw) INSTALL_OPENCLAW=1 ;;
+ --no-start) START_SERVICE=0 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
+ esac
+ shift
+done
+
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required command: $1" >&2
+ return 1
+ fi
+}
+
+require_command "$PYTHON"
+require_command systemctl
+if ((INSTALL_VIDEO)); then
+ if command -v ffprobe >/dev/null 2>&1; then
+ FFPROBE_SOURCE="$(command -v ffprobe)"
+ elif [[ -x "$STT_ROOT/bin/ffprobe" ]]; then
+ FFPROBE_SOURCE="$STT_ROOT/bin/ffprobe"
+ else
+ echo "Missing ffprobe. Install the ffmpeg package before the full video setup." >&2
+ exit 1
+ fi
+fi
+
+if ((INSTALL_OPENCLAW)); then
+ if ((!INSTALL_VIDEO)); then
+ echo "--with-openclaw cannot be combined with --core-only" >&2
+ exit 2
+ fi
+ require_command node
+ require_command npm
+fi
+
+case "$(uname -s)-$(uname -m)" in
+ Linux-x86_64) ;;
+ *) echo "VoiceSwitch Server supports Linux x86_64 with an NVIDIA GPU." >&2; exit 1 ;;
+esac
+
+if [[ "${VOICESWITCH_SETUP_VALIDATE_ONLY:-0}" == "1" ]]; then
+ "$PYTHON" -m py_compile \
+ "$SOURCE_ROOT/stt/server.py" \
+ "$SOURCE_ROOT/stt/transcribe.py" \
+ "$SOURCE_ROOT/video/video_pipeline.py" \
+ "$SOURCE_ROOT/video/transcribe_mitim_stt.py" \
+ "$SOURCE_ROOT/video/align_qwen.py" \
+ "$SOURCE_ROOT/video/diarize_pyannote.py" \
+ "$SOURCE_ROOT/video/enrich_ollama.py" \
+ "$SOURCE_ROOT/video/telegram_publication.py"
+ echo "VoiceSwitch Server installer validation completed."
+ exit 0
+fi
+
+mkdir -p "$STT_ROOT" "$STT_ROOT/work" "$STT_ROOT/bin" "$CACHE_ROOT" "$UNIT_ROOT" "$BIN_ROOT"
+"$PYTHON" -m venv "$STT_ROOT/venv"
+"$STT_ROOT/venv/bin/python" -m pip install --upgrade pip
+"$STT_ROOT/venv/bin/python" -m pip install -r "$SOURCE_ROOT/requirements-stt.txt"
+install -m 0644 "$SOURCE_ROOT/stt/server.py" "$STT_ROOT/server.py"
+install -m 0755 "$SOURCE_ROOT/stt/transcribe.py" "$STT_ROOT/transcribe.py"
+FFMPEG_SOURCE="$("$STT_ROOT/venv/bin/python" -c 'import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())')"
+ln -sfn "$FFMPEG_SOURCE" "$STT_ROOT/bin/ffmpeg"
+
+if ((INSTALL_VIDEO)); then
+ if [[ "$FFPROBE_SOURCE" != "$STT_ROOT/bin/ffprobe" ]]; then
+ ln -sfn "$FFPROBE_SOURCE" "$STT_ROOT/bin/ffprobe"
+ fi
+fi
+
+STT_ROOT="$STT_ROOT" CACHE_ROOT="$CACHE_ROOT" SOURCE_ROOT="$SOURCE_ROOT" UNIT_ROOT="$UNIT_ROOT" \
+ "$PYTHON" - <<'PY'
+import os
+from pathlib import Path
+
+template = Path(os.environ["SOURCE_ROOT"]) / "systemd" / "mitim-stt.service.in"
+content = template.read_text(encoding="utf-8")
+content = content.replace("@STT_ROOT@", os.environ["STT_ROOT"])
+content = content.replace("@CACHE_ROOT@", os.environ["CACHE_ROOT"])
+target = Path(os.environ["UNIT_ROOT"]) / "mitim-stt.service"
+target.write_text(content, encoding="utf-8")
+PY
+
+if ((INSTALL_VIDEO)); then
+ mkdir -p "$VIDEO_ROOT/pipeline"
+ "$PYTHON" -m venv "$VIDEO_ROOT/venv"
+ "$VIDEO_ROOT/venv/bin/python" -m pip install --upgrade pip
+ "$VIDEO_ROOT/venv/bin/python" -m pip install -r "$SOURCE_ROOT/requirements-video.txt"
+ install -m 0644 "$SOURCE_ROOT"/video/*.py "$VIDEO_ROOT/pipeline/"
+ cat > "$BIN_ROOT/voiceswitch-video" <`; состояние соседней или более новой транскрибации больше не
+может быть принято за состояние текущей.
+
+Инструмент `video_transcription_resume` продолжает тот же оборванный Flow,
+включая `failed` после смерти старого gateway-child процесса, без создания
+дубликата. `cancelled` и `succeeded` Flow не возобновляются.
+
+Успешный терминальный статус дополнительно возвращает `ok=true`. Это не даёт
+Codex-провайдеру ошибочно трактовать штатный `status=succeeded` как tool error и
+запускать лишние повторные проверки уже завершённой задачи.
+
+Проверки:
+
+```text
+node --check index.js
+node --test index.test.js source-validation.test.js tool-result.test.js worker-scope.test.js
+```
diff --git a/server/linux/openclaw-plugin/index.js b/server/linux/openclaw-plugin/index.js
new file mode 100644
index 0000000..db89d2e
--- /dev/null
+++ b/server/linux/openclaw-plugin/index.js
@@ -0,0 +1,617 @@
+import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { spawn } from "node:child_process";
+import { Type } from "typebox";
+import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
+import { inspectTelegramPublication } from "./publication-completion.js";
+import { expectsTelegramPublication, resolveTelegramPublicationTarget } from "./telegram-origin.js";
+import { validateVideoSource } from "./source-validation.js";
+import { toolResult } from "./tool-result.js";
+import { flowOutputRoot, newestJobState, sha256, videoStartFingerprint } from "./job-isolation.js";
+import {
+ isWorkerScopeActive,
+ stopWorkerScope,
+ workerScopeArgs,
+ workerScopeUnitName,
+} from "./worker-scope.js";
+
+const CONTROLLER_ID = "video-transcription-taskflow";
+const HOME = homedir();
+const PROJECT = process.env.VOICESWITCH_VIDEO_PROJECT
+ ?? join(HOME, ".local", "share", "mitim-video", "pipeline");
+const VIDEO_PYTHON = process.env.VOICESWITCH_VIDEO_PYTHON
+ ?? join(HOME, ".local", "share", "mitim-video", "venv", "bin", "python");
+const DEFAULT_FFMPEG = join(HOME, ".local", "share", "mitim-stt", "bin", "ffmpeg");
+const DEFAULT_FFPROBE = join(HOME, ".local", "share", "mitim-stt", "bin", "ffprobe");
+const DEFAULT_OUTPUT_ROOT = join(PROJECT, "artifacts");
+const DEFAULT_PIPELINE = join(PROJECT, "video_pipeline.py");
+const ALIGNER = `${VIDEO_PYTHON} ${join(PROJECT, "align_qwen.py")} --audio \"{audio}\" --transcript \"{transcript}\" --output \"{output}\"`;
+const DIARIZER = `${VIDEO_PYTHON} ${join(PROJECT, "diarize_pyannote.py")} --audio \"{audio}\" --transcript \"{transcript}\" --output \"{output}\"`;
+const TERMINAL = new Set(["succeeded", "failed", "cancelled", "lost"]);
+
+const profiles = ["quick", "standard", "interview", "multilingual", "deep"];
+const sourceKinds = ["auto", "youtube", "telegram", "local"];
+
+const startParameters = Type.Object({
+ idempotencyKey: Type.String({ minLength: 1, maxLength: 200, description: "Stable key reused for retries of the same video job." }),
+ source: Type.String({ description: "Exact YouTube URL or absolute local MediaPath. Never pass the literal marker media:." }),
+ profile: Type.Optional(Type.Union(profiles.map((value) => Type.Literal(value)))),
+ sourceKind: Type.Optional(Type.Union(sourceKinds.map((value) => Type.Literal(value)))),
+ title: Type.Optional(Type.String()),
+ force: Type.Optional(Type.Boolean()),
+ telegramChatId: Type.Optional(Type.String({ description: "Telegram target chat id; normally derived from trusted runtime context." })),
+ telegramSourceTopicId: Type.Optional(Type.Integer({ minimum: 1, description: "Telegram source topic id; normally derived from trusted runtime context." })),
+});
+const flowParameters = Type.Object({
+ flowId: Type.Optional(Type.String({ description: "TaskFlow id; latest flow is used when omitted." })),
+});
+
+const pluginConfigSchema = Type.Object({
+ outputRoot: Type.Optional(Type.String()),
+ pipelinePath: Type.Optional(Type.String()),
+ pythonPath: Type.Optional(Type.String()),
+ ffmpegPath: Type.Optional(Type.String()),
+ ffprobePath: Type.Optional(Type.String()),
+}, { additionalProperties: false });
+
+function getFlow(runtime, token) {
+ return token ? runtime.resolve(token) : runtime.findLatest();
+}
+
+function isAlive(pid) {
+ if (!Number.isInteger(pid) || pid <= 0) return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function isWorkerAlive(state) {
+ if (state?.unitName) {
+ if (isWorkerScopeActive(state.unitName)) return true;
+ return Date.now() - Number(state.startedAt ?? 0) < 10_000 && isAlive(Number(state.pid));
+ }
+ return isAlive(Number(state?.pid));
+}
+
+function readJson(path) {
+ try {
+ return JSON.parse(readFileSync(path, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function reserveVideoStart(baseOutputRoot, idempotencyKey, startFingerprint) {
+ const reservationRoot = join(resolve(baseOutputRoot), ".taskflow-start");
+ mkdirSync(reservationRoot, { recursive: true, mode: 0o700 });
+ const keyHash = sha256(idempotencyKey);
+ const reservationPath = join(reservationRoot, `${keyHash}.json`);
+ const reservation = {
+ schemaVersion: 1,
+ keyHash,
+ startFingerprint,
+ status: "reserved",
+ gatewayPid: process.pid,
+ reservedAt: Date.now(),
+ };
+ let descriptor;
+ try {
+ descriptor = openSync(reservationPath, "wx", 0o600);
+ writeFileSync(descriptor, `${JSON.stringify(reservation)}\n`, "utf8");
+ closeSync(descriptor);
+ return { path: reservationPath, data: reservation, created: true };
+ } catch (error) {
+ if (descriptor !== undefined) closeSync(descriptor);
+ if (error?.code !== "EEXIST") throw error;
+ const existing = readJson(reservationPath);
+ if (!existing || existing.keyHash !== keyHash) {
+ throw new Error("Video start reservation is corrupted; refusing a duplicate worker.");
+ }
+ if (existing.startFingerprint !== startFingerprint) {
+ throw new Error("Video idempotency key was already used with a different payload.");
+ }
+ return { path: reservationPath, data: existing, created: false };
+ }
+}
+
+function updateVideoStartReservation(reservationPath, patch) {
+ const current = readJson(reservationPath);
+ if (!current) throw new Error("Video start reservation disappeared.");
+ const temporary = `${reservationPath}.${process.pid}.tmp`;
+ writeFileSync(temporary, `${JSON.stringify({ ...current, ...patch, updatedAt: Date.now() })}\n`, { encoding: "utf8", mode: 0o600 });
+ renameSync(temporary, reservationPath);
+}
+
+function stageFromState(state) {
+ if (!state || typeof state !== "object") return "processing";
+ const stage = String(state.stage ?? state.current_stage ?? state.status ?? "processing");
+ return ["complete", "completed", "succeeded"].includes(stage.toLowerCase()) ? "ready" : stage;
+}
+
+function settleStoppedFlow(runtime, flow, nextState, stage) {
+ if (!["ready", "completed", "complete", "succeeded"].includes(stage.toLowerCase())) {
+ return runtime.fail({
+ flowId: flow.flowId,
+ expectedRevision: flow.revision,
+ stateJson: nextState,
+ blockedSummary: `Video worker stopped at stage: ${stage}`,
+ });
+ }
+
+ const publication = inspectTelegramPublication(
+ nextState.jobState,
+ Boolean(nextState.publicationTarget),
+ );
+ const stateJson = { ...nextState, publication };
+ if (publication.required && !publication.complete) {
+ return runtime.setWaiting({
+ flowId: flow.flowId,
+ expectedRevision: flow.revision,
+ currentStep: "waiting_publication",
+ stateJson,
+ waitJson: {
+ kind: "telegram_publication",
+ outboxPath: publication.outboxPath,
+ deliveryStatus: publication.status,
+ missing: publication.missing,
+ },
+ blockedSummary: "Local processing is complete; Telegram publication is still pending.",
+ });
+ }
+ return runtime.finish({
+ flowId: flow.flowId,
+ expectedRevision: flow.revision,
+ stateJson,
+ });
+}
+
+function refreshFlow(runtime, flow) {
+ if (!flow || TERMINAL.has(flow.status)) return flow;
+ const state = flow.stateJson && typeof flow.stateJson === "object" ? flow.stateJson : {};
+ const pid = Number(state.pid);
+ const job = newestJobState(String(state.outputRoot ?? DEFAULT_OUTPUT_ROOT), Number(state.startedAt ?? 0));
+ const stage = stageFromState(job?.data);
+ const nextState = { ...state, stage, ...(job ? { jobStatePath: job.path, jobState: job.data } : {}) };
+
+ if (isWorkerAlive(state)) {
+ const updated = runtime.resume({
+ flowId: flow.flowId,
+ expectedRevision: flow.revision,
+ status: "running",
+ currentStep: stage,
+ stateJson: nextState,
+ });
+ return updated.applied ? updated.flow : updated.current ?? flow;
+ }
+
+ const result = settleStoppedFlow(runtime, flow, nextState, stage);
+ return result.applied ? result.flow : result.current ?? flow;
+}
+
+function pipelineArgs({
+ source,
+ profile,
+ sourceKind,
+ title,
+ force,
+ pipelinePath,
+ outputRoot,
+ ffmpegPath,
+ ffprobePath,
+ publicationTarget,
+}) {
+ const args = [
+ pipelinePath,
+ source,
+ "--profile", profile,
+ "--source-kind", sourceKind ?? "auto",
+ "--output-root", outputRoot,
+ "--align-command", ALIGNER,
+ "--diarize-command", DIARIZER,
+ "--ffmpeg-path", ffmpegPath,
+ "--ffprobe-path", ffprobePath,
+ ];
+ if (title) args.push("--title", title);
+ if (force) args.push("--force");
+ if (publicationTarget) {
+ args.push("--telegram-chat-id", publicationTarget.chatId);
+ if (publicationTarget.sourceTopicId) {
+ args.push("--telegram-source-topic-id", String(publicationTarget.sourceTopicId));
+ }
+ }
+ return args;
+}
+
+function launchWorker({ flowId, pythonPath, args, logPath, ffmpegPath, ffprobePath }) {
+ const unitName = workerScopeUnitName(flowId);
+ const logFd = openSync(logPath, "a");
+ let child;
+ try {
+ child = spawn("/usr/bin/systemd-run", workerScopeArgs(unitName, pythonPath, args), {
+ detached: true,
+ stdio: ["ignore", logFd, logFd],
+ env: {
+ ...process.env,
+ PATH: [dirname(ffmpegPath), dirname(ffprobePath), process.env.PATH].filter(Boolean).join(":"),
+ },
+ });
+ } finally {
+ closeSync(logFd);
+ }
+ if (!child.pid) throw new Error("Video worker scope did not return a PID.");
+ return { child, unitName };
+}
+
+function finishFromExit(runtime, flowId, code, signal) {
+ let flow = runtime.get(flowId);
+ if (!flow || TERMINAL.has(flow.status)) return;
+ const state = flow.stateJson && typeof flow.stateJson === "object" ? flow.stateJson : {};
+ const job = newestJobState(String(state.outputRoot ?? DEFAULT_OUTPUT_ROOT), Number(state.startedAt ?? 0));
+ const stage = stageFromState(job?.data);
+ const nextState = {
+ ...state,
+ stage,
+ ...(job ? { jobStatePath: job.path, jobState: job.data } : {}),
+ exitCode: code,
+ exitSignal: signal ?? null,
+ };
+ if (code === 0) {
+ settleStoppedFlow(runtime, flow, nextState, stage);
+ } else {
+ runtime.fail({
+ flowId,
+ expectedRevision: flow.revision,
+ stateJson: nextState,
+ blockedSummary: `Video worker exited with code ${code ?? "unknown"}`,
+ });
+ }
+}
+
+function createStartTool(api, config, toolContext) {
+ const runtime = api.runtime.tasks.managedFlows.fromToolContext(toolContext);
+ return {
+ name: "video_transcription_start",
+ label: "Start video transcription",
+ description: "Start the local GigaAM-first video pipeline and track it as a managed TaskFlow.",
+ parameters: startParameters,
+ execute: async (_toolCallId, params) => {
+ const profile = params.profile ?? "standard";
+ // Fail before createManaged/spawn so an invalid attachment cannot be
+ // reported as "started" and then disappear as a failed background job.
+ const source = validateVideoSource(params.source);
+ const idempotencyKey = String(params.idempotencyKey ?? "").trim();
+ if (!idempotencyKey || idempotencyKey.length > 200) {
+ throw new Error("video_transcription_start requires a stable idempotencyKey.");
+ }
+ const publicationTarget = resolveTelegramPublicationTarget(toolContext, params);
+ if (expectsTelegramPublication(toolContext) && !publicationTarget) {
+ throw new Error("Telegram context detected, but publication chat could not be resolved.");
+ }
+
+ const pipelinePath = resolve(config.pipelinePath ?? DEFAULT_PIPELINE);
+ const pythonPath = config.pythonPath ? resolve(config.pythonPath) : "/usr/bin/python3";
+ const ffmpegPath = resolve(config.ffmpegPath ?? DEFAULT_FFMPEG);
+ const ffprobePath = resolve(config.ffprobePath ?? DEFAULT_FFPROBE);
+ const baseOutputRoot = resolve(config.outputRoot ?? DEFAULT_OUTPUT_ROOT);
+ if (!existsSync(pipelinePath)) throw new Error(`Pipeline not found: ${pipelinePath}`);
+ if (!existsSync(ffmpegPath)) throw new Error(`ffmpeg not found: ${ffmpegPath}`);
+ if (!existsSync(ffprobePath)) throw new Error(`ffprobe not found: ${ffprobePath}`);
+ mkdirSync(baseOutputRoot, { recursive: true });
+ const startFingerprint = videoStartFingerprint(params, source, publicationTarget);
+ const existing = runtime.list().find((candidate) => {
+ const state = candidate?.stateJson && typeof candidate.stateJson === "object" ? candidate.stateJson : {};
+ return candidate.controllerId === CONTROLLER_ID && state.startIdempotencyKey === idempotencyKey;
+ });
+ if (existing) {
+ const state = existing.stateJson && typeof existing.stateJson === "object" ? existing.stateJson : {};
+ if (state.startFingerprint !== startFingerprint) {
+ throw new Error("Video idempotency key was already used with a different payload.");
+ }
+ return toolResult({ ...refreshFlow(runtime, existing), idempotent: true, duplicateWorkerCreated: false });
+ }
+ const reservation = reserveVideoStart(baseOutputRoot, idempotencyKey, startFingerprint);
+ if (!reservation.created) {
+ if (reservation.data.flowId) {
+ const reservedFlow = runtime.get(reservation.data.flowId);
+ if (reservedFlow) {
+ return toolResult({ ...refreshFlow(runtime, reservedFlow), idempotent: true, duplicateWorkerCreated: false });
+ }
+ throw new Error(`Video job already belongs to TaskFlow ${reservation.data.flowId}; refusing a cross-session duplicate.`);
+ }
+ throw new Error("A previous video start stopped before its TaskFlow id was recorded; manual reconcile is required and no duplicate was started.");
+ }
+ const jobId = sha256(idempotencyKey).slice(0, 24);
+ const outputRoot = flowOutputRoot(baseOutputRoot, jobId);
+ mkdirSync(outputRoot, { recursive: true });
+ const logRoot = join(outputRoot, "logs");
+ mkdirSync(logRoot, { recursive: true });
+
+ const startedAt = Date.now();
+ const flow = runtime.createManaged({
+ controllerId: CONTROLLER_ID,
+ goal: `Transcribe video: ${params.title || basename(source) || source}`,
+ status: "running",
+ notifyPolicy: "state_changes",
+ currentStep: "received",
+ stateJson: {
+ source,
+ sourceHash: sha256(source),
+ profile,
+ baseOutputRoot,
+ outputRoot,
+ jobId,
+ startIdempotencyKey: idempotencyKey,
+ startFingerprint,
+ startedAt,
+ publicationTarget,
+ },
+ waitJson: { kind: "local_process" },
+ });
+ updateVideoStartReservation(reservation.path, { status: "flow_created", flowId: flow.flowId, jobId });
+
+ const logPath = join(logRoot, `${flow.flowId}.log`);
+ const args = pipelineArgs({
+ source,
+ profile,
+ sourceKind: params.sourceKind,
+ title: params.title,
+ force: params.force,
+ pipelinePath,
+ outputRoot,
+ ffmpegPath,
+ ffprobePath,
+ publicationTarget,
+ });
+
+ let launched;
+ try {
+ launched = launchWorker({ flowId: flow.flowId, pythonPath, args, logPath, ffmpegPath, ffprobePath });
+ } catch (error) {
+ const current = runtime.get(flow.flowId) ?? flow;
+ runtime.fail({
+ flowId: current.flowId,
+ expectedRevision: current.revision,
+ stateJson: { ...current.stateJson, stage: "worker_launch_failed" },
+ blockedSummary: `Video worker launch failed: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ updateVideoStartReservation(reservation.path, { status: "failed", flowId: flow.flowId, jobId });
+ throw error;
+ }
+ const { child, unitName } = launched;
+ if (!child.pid) {
+ finishFromExit(runtime, flow.flowId, null, "spawn_failed");
+ throw new Error("Video worker did not return a PID.");
+ }
+
+ const current = runtime.get(flow.flowId);
+ const stateJson = {
+ source,
+ sourceHash: sha256(source),
+ profile,
+ baseOutputRoot,
+ outputRoot,
+ jobId,
+ startIdempotencyKey: idempotencyKey,
+ startFingerprint,
+ startedAt,
+ pid: child.pid,
+ unitName,
+ logPath,
+ ffmpegPath,
+ ffprobePath,
+ sourceKind: params.sourceKind ?? "auto",
+ title: params.title ?? null,
+ force: params.force === true,
+ publicationTarget,
+ };
+ if (current) {
+ runtime.resume({
+ flowId: flow.flowId,
+ expectedRevision: current.revision,
+ status: "running",
+ currentStep: "received",
+ stateJson,
+ });
+ }
+ child.once("exit", (code, signal) => finishFromExit(runtime, flow.flowId, code, signal));
+ child.once("error", () => finishFromExit(runtime, flow.flowId, null, "spawn_error"));
+ child.unref();
+ updateVideoStartReservation(reservation.path, { status: "worker_started", flowId: flow.flowId, jobId, unitName });
+
+ return toolResult({ flowId: flow.flowId, jobId, status: "running", pid: child.pid, logPath, profile, publicationTarget, idempotent: false });
+ },
+ };
+}
+
+function createResumeTool(api, config, toolContext) {
+ const runtime = api.runtime.tasks.managedFlows.fromToolContext(toolContext);
+ return {
+ name: "video_transcription_resume",
+ label: "Resume video transcription",
+ description: "Resume the same interrupted video TaskFlow without creating a duplicate Flow.",
+ parameters: flowParameters,
+ execute: async (_toolCallId, params) => {
+ let flow = getFlow(runtime, params.flowId);
+ if (!flow) throw new Error("Video transcription TaskFlow was not found.");
+ if (TERMINAL.has(flow.status) && flow.status !== "failed") {
+ throw new Error(`Video transcription TaskFlow cannot be resumed from: ${flow.status}`);
+ }
+ const state = flow.stateJson && typeof flow.stateJson === "object" ? flow.stateJson : {};
+ if (isWorkerAlive(state)) return toolResult({ ...flow, idempotent: true, workerActive: true });
+
+ const completedJob = newestJobState(
+ String(state.outputRoot ?? DEFAULT_OUTPUT_ROOT),
+ Number(state.startedAt ?? 0),
+ );
+ const completedStage = stageFromState(completedJob?.data);
+ if (["ready", "completed", "complete", "succeeded"].includes(completedStage.toLowerCase())) {
+ const settled = settleStoppedFlow(runtime, flow, {
+ ...state,
+ stage: completedStage,
+ jobStatePath: completedJob.path,
+ jobState: completedJob.data,
+ }, completedStage);
+ return toolResult({ ...(settled.applied ? settled.flow : settled.current ?? flow), idempotent: true, workerActive: false });
+ }
+ const source = validateVideoSource(state.source);
+ const profile = profiles.includes(state.profile) ? state.profile : "standard";
+ const pipelinePath = resolve(config.pipelinePath ?? DEFAULT_PIPELINE);
+ const pythonPath = config.pythonPath ? resolve(config.pythonPath) : "/usr/bin/python3";
+ const ffmpegPath = resolve(state.ffmpegPath ?? config.ffmpegPath ?? DEFAULT_FFMPEG);
+ const ffprobePath = resolve(state.ffprobePath ?? config.ffprobePath ?? DEFAULT_FFPROBE);
+ const outputRoot = resolve(state.outputRoot ?? config.outputRoot ?? DEFAULT_OUTPUT_ROOT);
+ const logPath = resolve(state.logPath ?? join(outputRoot, "logs", `${flow.flowId}.log`));
+ if (!existsSync(pipelinePath)) throw new Error(`Pipeline not found: ${pipelinePath}`);
+ if (!existsSync(ffmpegPath)) throw new Error(`ffmpeg not found: ${ffmpegPath}`);
+ if (!existsSync(ffprobePath)) throw new Error(`ffprobe not found: ${ffprobePath}`);
+ mkdirSync(dirname(logPath), { recursive: true });
+ const startedAt = Date.now();
+ const args = pipelineArgs({
+ source,
+ profile,
+ sourceKind: state.sourceKind,
+ title: state.title,
+ force: false,
+ pipelinePath,
+ outputRoot,
+ ffmpegPath,
+ ffprobePath,
+ publicationTarget: state.publicationTarget ?? null,
+ });
+ const { child, unitName } = launchWorker({
+ flowId: flow.flowId,
+ pythonPath,
+ args,
+ logPath,
+ ffmpegPath,
+ ffprobePath,
+ });
+ const resumed = runtime.resume({
+ flowId: flow.flowId,
+ expectedRevision: flow.revision,
+ status: "running",
+ currentStep: "resumed_after_gateway_restart",
+ stateJson: {
+ ...state,
+ originalStartedAt: state.originalStartedAt ?? state.startedAt,
+ startedAt,
+ pid: child.pid,
+ unitName,
+ recoveryCount: Number(state.recoveryCount ?? 0) + 1,
+ recoveredAt: startedAt,
+ stage: "resumed_after_gateway_restart",
+ },
+ });
+ if (!resumed.applied) {
+ stopWorkerScope(unitName);
+ throw new Error(`Video TaskFlow changed during recovery: ${resumed.code ?? "revision conflict"}`);
+ }
+ child.once("exit", (code, signal) => finishFromExit(runtime, flow.flowId, code, signal));
+ child.once("error", () => finishFromExit(runtime, flow.flowId, null, "spawn_error"));
+ child.unref();
+ return toolResult({
+ flowId: flow.flowId,
+ status: "running",
+ resumed: true,
+ duplicateFlowCreated: false,
+ pid: child.pid,
+ unitName,
+ });
+ },
+ };
+}
+
+function createStatusTool(api, toolContext) {
+ const runtime = api.runtime.tasks.managedFlows.fromToolContext(toolContext);
+ return {
+ name: "video_transcription_status",
+ label: "Video transcription status",
+ description: "Refresh and return the latest state of a managed video transcription TaskFlow.",
+ parameters: flowParameters,
+ execute: async (_toolCallId, params) => {
+ const flow = getFlow(runtime, params.flowId);
+ if (!flow) throw new Error("Video transcription TaskFlow was not found.");
+ return toolResult(refreshFlow(runtime, flow));
+ },
+ };
+}
+
+function createCancelTool(api, toolContext) {
+ const runtime = api.runtime.tasks.managedFlows.fromToolContext(toolContext);
+ return {
+ name: "video_transcription_cancel",
+ label: "Cancel video transcription",
+ description: "Terminate the local worker and cancel its managed video transcription TaskFlow.",
+ parameters: flowParameters,
+ execute: async (_toolCallId, params) => {
+ const flow = getFlow(runtime, params.flowId);
+ if (!flow) throw new Error("Video transcription TaskFlow was not found.");
+ const state = flow.stateJson && typeof flow.stateJson === "object" ? flow.stateJson : {};
+ const pid = Number(state.pid);
+ if (state.unitName) {
+ stopWorkerScope(state.unitName);
+ } else if (isAlive(pid)) {
+ try {
+ process.kill(-pid, "SIGTERM");
+ } catch {
+ process.kill(pid, "SIGTERM");
+ }
+ }
+ const requested = runtime.requestCancel({ flowId: flow.flowId, expectedRevision: flow.revision });
+ const current = requested.applied ? requested.flow : requested.current ?? flow;
+ const cfg = toolContext.getRuntimeConfig?.() ?? toolContext.runtimeConfig ?? toolContext.config;
+ if (!cfg) return toolResult(current);
+ const cancelled = await runtime.cancel({ flowId: flow.flowId, cfg });
+ return toolResult(cancelled);
+ },
+ };
+}
+
+export default defineToolPlugin({
+ id: CONTROLLER_ID,
+ name: "Video Transcription TaskFlow",
+ description: "Managed background orchestration for the local GigaAM-first video pipeline.",
+ configSchema: pluginConfigSchema,
+ tools: (tool) => [
+ tool({
+ name: "video_transcription_start",
+ label: "Start video transcription",
+ description: "Start the local GigaAM-first video pipeline and track it as a managed TaskFlow.",
+ parameters: startParameters,
+ factory: ({ api, config, toolContext }) => (
+ toolContext?.sessionKey ? createStartTool(api, config, toolContext) : null
+ ),
+ }),
+ tool({
+ name: "video_transcription_resume",
+ label: "Resume video transcription",
+ description: "Resume the same interrupted video TaskFlow without creating a duplicate Flow.",
+ parameters: flowParameters,
+ factory: ({ api, config, toolContext }) => (
+ toolContext?.sessionKey ? createResumeTool(api, config, toolContext) : null
+ ),
+ }),
+ tool({
+ name: "video_transcription_status",
+ label: "Video transcription status",
+ description: "Refresh and return a managed video transcription TaskFlow.",
+ parameters: flowParameters,
+ factory: ({ api, toolContext }) => (
+ toolContext?.sessionKey ? createStatusTool(api, toolContext) : null
+ ),
+ }),
+ tool({
+ name: "video_transcription_cancel",
+ label: "Cancel video transcription",
+ description: "Cancel a managed video transcription TaskFlow.",
+ parameters: flowParameters,
+ factory: ({ api, toolContext }) => (
+ toolContext?.sessionKey ? createCancelTool(api, toolContext) : null
+ ),
+ }),
+ ],
+});
diff --git a/server/linux/openclaw-plugin/index.test.js b/server/linux/openclaw-plugin/index.test.js
new file mode 100644
index 0000000..e002d82
--- /dev/null
+++ b/server/linux/openclaw-plugin/index.test.js
@@ -0,0 +1,127 @@
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+
+import { flowOutputRoot, newestJobState, videoStartFingerprint } from "./job-isolation.js";
+
+import { inspectTelegramPublication } from "./publication-completion.js";
+import {
+ expectsTelegramPublication,
+ resolveTelegramPublicationTarget,
+ telegramOriginFromDestination,
+ telegramOriginFromSessionKey,
+} from "./telegram-origin.js";
+
+test("uses the OpenClaw 2026.9 managed TaskFlow runtime", () => {
+ const source = readFileSync(new URL("./index.js", import.meta.url), "utf8");
+ assert.match(source, /api\.runtime\.tasks\.managedFlows\.fromToolContext/);
+ assert.doesNotMatch(source, /api\.runtime\.tasks\.flow\./);
+});
+
+test("parses Telegram topic session keys", () => {
+ assert.deepEqual(
+ telegramOriginFromSessionKey("agent:main:telegram:group:-1001234567890:topic:8"),
+ { chatId: "-1001234567890", sourceTopicId: 8 },
+ );
+});
+
+test("parses marked Telegram destinations", () => {
+ assert.deepEqual(
+ telegramOriginFromDestination("telegram:-1001234567890:topic:8"),
+ { chatId: "-1001234567890", sourceTopicId: 8 },
+ );
+});
+
+test("trusted delivery context becomes the publication target", () => {
+ const target = resolveTelegramPublicationTarget({
+ deliveryContext: { channel: "telegram", to: "-1001234567890", threadId: "8" },
+ });
+ assert.deepEqual(target, { chatId: "-1001234567890", sourceTopicId: 8 });
+});
+
+test("session key is a fallback when delivery context is absent", () => {
+ const target = resolveTelegramPublicationTarget({
+ messageChannel: "telegram",
+ sessionKey: "agent:main:telegram:group:-1001234567890:topic:8",
+ });
+ assert.deepEqual(target, { chatId: "-1001234567890", sourceTopicId: 8 });
+ assert.equal(expectsTelegramPublication({ messageChannel: "telegram" }), true);
+});
+
+test("non-Telegram calls do not gain a publication target", () => {
+ assert.equal(resolveTelegramPublicationTarget({ messageChannel: "webchat" }), null);
+ assert.equal(expectsTelegramPublication({ messageChannel: "webchat" }), false);
+});
+
+test("parallel video jobs only inspect their own output directory", (t) => {
+ const root = mkdtempSync(join(tmpdir(), "video-isolation-test-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const firstRoot = flowOutputRoot(root, "a".repeat(24));
+ const secondRoot = flowOutputRoot(root, "b".repeat(24));
+ mkdirSync(join(firstRoot, "result"), { recursive: true });
+ mkdirSync(join(secondRoot, "result"), { recursive: true });
+ writeFileSync(join(firstRoot, "result", "job-state.json"), JSON.stringify({ stage: "complete", owner: "first" }));
+ writeFileSync(join(secondRoot, "result", "job-state.json"), JSON.stringify({ stage: "complete", owner: "second" }));
+ assert.equal(newestJobState(firstRoot, 0).data.owner, "first");
+ assert.equal(newestJobState(secondRoot, 0).data.owner, "second");
+});
+
+test("video start fingerprint is stable and payload-sensitive", () => {
+ const params = { profile: "standard", sourceKind: "youtube", title: "One" };
+ const target = { chatId: "-1001", sourceTopicId: 7 };
+ const first = videoStartFingerprint(params, "https://youtu.be/example", target);
+ assert.equal(first, videoStartFingerprint(params, "https://youtu.be/example", target));
+ assert.notEqual(first, videoStartFingerprint({ ...params, profile: "deep" }, "https://youtu.be/example", target));
+});
+
+function publicationFixture(t, delivery) {
+ const root = mkdtempSync(join(tmpdir(), "video-publication-test-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const outbox = join(root, "telegram-publication.json");
+ writeFileSync(outbox, JSON.stringify({ delivery }), "utf8");
+ return { stages: { publication: { status: "ready", outbox } } };
+}
+
+test("pending Telegram delivery cannot complete the flow", (t) => {
+ const job = publicationFixture(t, {
+ status: "pending_topic",
+ topic_id: null,
+ messages: {},
+ completed_at: null,
+ });
+ const result = inspectTelegramPublication(job);
+ assert.equal(result.complete, false);
+ assert.ok(result.missing.includes("topic_id"));
+ assert.ok(result.missing.includes("delivery.status"));
+});
+
+test("completed status alone is insufficient without message ids", (t) => {
+ const job = publicationFixture(t, {
+ status: "completed",
+ topic_id: 777,
+ messages: { status: 10, summary: 11 },
+ completed_at: "2026-08-30T07:00:00+00:00",
+ });
+ const result = inspectTelegramPublication(job);
+ assert.equal(result.complete, false);
+ assert.deepEqual(result.missing, ["message:summary_document", "message:transcript_document"]);
+});
+
+test("flow completion requires topic and every publication message", (t) => {
+ const job = publicationFixture(t, {
+ status: "completed",
+ topic_id: 777,
+ messages: {
+ status: 10,
+ summary: 11,
+ summary_document: 12,
+ transcript_document: 13,
+ },
+ completed_at: "2026-08-30T07:00:00+00:00",
+ });
+ const result = inspectTelegramPublication(job);
+ assert.equal(result.complete, true);
+ assert.deepEqual(result.missing, []);
+});
diff --git a/server/linux/openclaw-plugin/job-isolation.js b/server/linux/openclaw-plugin/job-isolation.js
new file mode 100644
index 0000000..80e4839
--- /dev/null
+++ b/server/linux/openclaw-plugin/job-isolation.js
@@ -0,0 +1,59 @@
+import { createHash } from "node:crypto";
+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import { join, resolve } from "node:path";
+
+export function sha256(value) {
+ return createHash("sha256").update(String(value)).digest("hex");
+}
+
+export function videoStartFingerprint(params, source, publicationTarget) {
+ return sha256(JSON.stringify({
+ sourceHash: sha256(source),
+ profile: params.profile ?? "standard",
+ sourceKind: params.sourceKind ?? "auto",
+ title: params.title ?? null,
+ force: params.force === true,
+ publicationTarget: publicationTarget ?? null,
+ }));
+}
+
+export function flowOutputRoot(baseOutputRoot, jobId) {
+ if (!/^[a-f0-9]{24}$/.test(jobId)) throw new Error("Unsupported video job id.");
+ return join(resolve(baseOutputRoot), "jobs", jobId);
+}
+
+function readJson(path) {
+ try {
+ return JSON.parse(readFileSync(path, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+export function newestJobState(root, startedAt) {
+ if (!existsSync(root)) return null;
+ let newest = null;
+ const queue = [{ path: root, depth: 0 }];
+ while (queue.length) {
+ const current = queue.shift();
+ if (current.depth > 5) continue;
+ let entries;
+ try {
+ entries = readdirSync(current.path, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ const path = join(current.path, entry.name);
+ if (entry.isDirectory()) {
+ queue.push({ path, depth: current.depth + 1 });
+ } else if (entry.name === "job-state.json") {
+ const modified = statSync(path).mtimeMs;
+ if (modified >= startedAt - 5000 && (!newest || modified > newest.modified)) {
+ newest = { path, modified, data: readJson(path) };
+ }
+ }
+ }
+ }
+ return newest;
+}
diff --git a/server/linux/openclaw-plugin/openclaw.plugin.json b/server/linux/openclaw-plugin/openclaw.plugin.json
new file mode 100644
index 0000000..bd46491
--- /dev/null
+++ b/server/linux/openclaw-plugin/openclaw.plugin.json
@@ -0,0 +1,38 @@
+{
+ "id": "video-transcription-taskflow",
+ "name": "Video Transcription TaskFlow",
+ "description": "Managed background orchestration for the local GigaAM-first video pipeline.",
+ "version": "0.1.6",
+ "configSchema": {
+ "type": "object",
+ "properties": {
+ "outputRoot": {
+ "type": "string"
+ },
+ "pipelinePath": {
+ "type": "string"
+ },
+ "pythonPath": {
+ "type": "string"
+ },
+ "ffmpegPath": {
+ "type": "string"
+ },
+ "ffprobePath": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "activation": {
+ "onStartup": true
+ },
+ "contracts": {
+ "tools": [
+ "video_transcription_start",
+ "video_transcription_resume",
+ "video_transcription_status",
+ "video_transcription_cancel"
+ ]
+ }
+}
diff --git a/server/linux/openclaw-plugin/package-lock.json b/server/linux/openclaw-plugin/package-lock.json
new file mode 100644
index 0000000..e9b278c
--- /dev/null
+++ b/server/linux/openclaw-plugin/package-lock.json
@@ -0,0 +1,24 @@
+{
+ "name": "openclaw-plugin-video-transcription-taskflow",
+ "version": "0.1.6",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openclaw-plugin-video-transcription-taskflow",
+ "version": "0.1.6",
+ "dependencies": {
+ "typebox": "1.3.3"
+ },
+ "peerDependencies": {
+ "openclaw": ">=2026.7.1"
+ }
+ },
+ "node_modules/typebox": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
+ "integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/server/linux/openclaw-plugin/package.json b/server/linux/openclaw-plugin/package.json
new file mode 100644
index 0000000..71cefb4
--- /dev/null
+++ b/server/linux/openclaw-plugin/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "openclaw-plugin-video-transcription-taskflow",
+ "version": "0.1.6",
+ "type": "module",
+ "private": true,
+ "peerDependencies": {
+ "openclaw": ">=2026.7.1"
+ },
+ "dependencies": {
+ "typebox": "1.3.3"
+ },
+ "openclaw": {
+ "extensions": [
+ "./index.js"
+ ]
+ }
+}
diff --git a/server/linux/openclaw-plugin/publication-completion.js b/server/linux/openclaw-plugin/publication-completion.js
new file mode 100644
index 0000000..05a0ac5
--- /dev/null
+++ b/server/linux/openclaw-plugin/publication-completion.js
@@ -0,0 +1,74 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const REQUIRED_PUBLICATION_MESSAGES = ["status", "summary", "summary_document", "transcript_document"];
+
+function readJson(path) {
+ try {
+ return JSON.parse(readFileSync(path, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+function positiveInteger(value) {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed > 0;
+}
+
+function publicationPathFromJob(job) {
+ const stages = job?.stages && typeof job.stages === "object" ? job.stages : {};
+ const publication = stages.publication && typeof stages.publication === "object"
+ ? stages.publication
+ : {};
+ return typeof publication.outbox === "string" && publication.outbox.trim()
+ ? resolve(publication.outbox)
+ : null;
+}
+
+export function inspectTelegramPublication(job, publicationExpected = true) {
+ if (!publicationExpected) {
+ return { required: false, complete: true, status: "skipped", missing: [] };
+ }
+ const outboxPath = publicationPathFromJob(job);
+ if (!outboxPath) {
+ return {
+ required: true,
+ complete: false,
+ status: "missing_outbox",
+ outboxPath: null,
+ missing: ["outbox"],
+ };
+ }
+ const plan = readJson(outboxPath);
+ if (!plan) {
+ return {
+ required: true,
+ complete: false,
+ status: "unreadable_outbox",
+ outboxPath,
+ missing: ["outbox"],
+ };
+ }
+ const delivery = plan.delivery && typeof plan.delivery === "object" ? plan.delivery : {};
+ const messages = delivery.messages && typeof delivery.messages === "object" ? delivery.messages : {};
+ const missing = [];
+ if (!positiveInteger(delivery.topic_id)) missing.push("topic_id");
+ for (const kind of REQUIRED_PUBLICATION_MESSAGES) {
+ if (!positiveInteger(messages[kind])) missing.push(`message:${kind}`);
+ }
+ if (typeof delivery.completed_at !== "string" || !delivery.completed_at.trim()) {
+ missing.push("completed_at");
+ }
+ if (delivery.status !== "completed") missing.push("delivery.status");
+ return {
+ required: true,
+ complete: missing.length === 0,
+ status: String(delivery.status ?? "missing"),
+ outboxPath,
+ topicId: positiveInteger(delivery.topic_id) ? Number(delivery.topic_id) : null,
+ messages,
+ completedAt: delivery.completed_at ?? null,
+ missing,
+ };
+}
diff --git a/server/linux/openclaw-plugin/source-validation.js b/server/linux/openclaw-plugin/source-validation.js
new file mode 100644
index 0000000..9968172
--- /dev/null
+++ b/server/linux/openclaw-plugin/source-validation.js
@@ -0,0 +1,49 @@
+import { existsSync, statSync } from "node:fs";
+import { isAbsolute, resolve } from "node:path";
+
+const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"]);
+
+function isYoutubeUrl(value) {
+ try {
+ const parsed = new URL(value);
+ return ["http:", "https:"].includes(parsed.protocol) && YOUTUBE_HOSTS.has(parsed.hostname.toLowerCase());
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validate before creating a managed flow. In particular, `media:` is an
+ * inbound-media marker, not a filesystem path; accepting it creates a flow
+ * that can only fail after the user has already been told it started.
+ */
+export function validateVideoSource(input) {
+ const source = typeof input === "string" ? input.trim() : "";
+ if (!source) {
+ throw new Error("Источник видео пуст. Передай точный MediaPath или YouTube-ссылку.");
+ }
+ if (/^media(?::|$)/i.test(source)) {
+ throw new Error("Получен маркер `media:`, а не путь к файлу. Передай точный MediaPath из входящего вложения.");
+ }
+ if (/^https?:\/\//i.test(source)) {
+ if (!isYoutubeUrl(source)) {
+ throw new Error("Поддерживаются только YouTube-ссылки; Instagram/Reels нужно скачать и передать как видеофайл.");
+ }
+ return source;
+ }
+
+ const path = isAbsolute(source) ? source : resolve(source);
+ if (!existsSync(path)) {
+ throw new Error(`Файл видео не найден: ${path}. Обработка не запускалась.`);
+ }
+ let stats;
+ try {
+ stats = statSync(path);
+ } catch {
+ throw new Error(`Невозможно прочитать файл видео: ${path}. Обработка не запускалась.`);
+ }
+ if (!stats.isFile()) {
+ throw new Error(`Источник не является файлом: ${path}. Обработка не запускалась.`);
+ }
+ return path;
+}
diff --git a/server/linux/openclaw-plugin/source-validation.test.js b/server/linux/openclaw-plugin/source-validation.test.js
new file mode 100644
index 0000000..fdec04d
--- /dev/null
+++ b/server/linux/openclaw-plugin/source-validation.test.js
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { validateVideoSource } from "./source-validation.js";
+
+test("rejects media marker before a flow can be created", () => {
+ assert.throws(
+ () => validateVideoSource("media:"),
+ /маркер `media:`/,
+ );
+});
+
+test("rejects a missing local file", () => {
+ assert.throws(
+ () => validateVideoSource(join(tmpdir(), "video-does-not-exist.mp4")),
+ /Файл видео не найден/,
+ );
+});
+
+test("returns an existing regular file as an absolute path", () => {
+ const root = mkdtempSync(join(tmpdir(), "video-source-"));
+ const file = join(root, "clip.mp4");
+ writeFileSync(file, "test");
+ assert.equal(validateVideoSource(file), file);
+});
+
+test("accepts YouTube URLs and rejects unsupported web sources", () => {
+ assert.equal(validateVideoSource("https://youtu.be/abc123"), "https://youtu.be/abc123");
+ assert.throws(
+ () => validateVideoSource("https://www.instagram.com/reel/abc123/"),
+ /только YouTube-ссылки/,
+ );
+});
diff --git a/server/linux/openclaw-plugin/telegram-origin.js b/server/linux/openclaw-plugin/telegram-origin.js
new file mode 100644
index 0000000..8a3d98c
--- /dev/null
+++ b/server/linux/openclaw-plugin/telegram-origin.js
@@ -0,0 +1,57 @@
+function positiveInteger(value) {
+ if (value === null || value === undefined || value === "") return null;
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+}
+
+export function telegramOriginFromSessionKey(sessionKey) {
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return null;
+ const parts = sessionKey.split(":");
+ const channelIndex = parts.indexOf("telegram");
+ if (channelIndex < 0) return null;
+ const kind = parts[channelIndex + 1];
+ const chatId = parts[channelIndex + 2];
+ if (!chatId || !["direct", "group"].includes(kind)) return null;
+ const topicIndex = parts.indexOf("topic", channelIndex + 3);
+ return {
+ chatId,
+ sourceTopicId: topicIndex >= 0 ? positiveInteger(parts[topicIndex + 1]) : null,
+ };
+}
+
+export function telegramOriginFromDestination(value) {
+ if (value === null || value === undefined) return null;
+ const rendered = String(value).trim();
+ if (!rendered) return null;
+ const match = rendered.match(/^(?:telegram:)?(-?\d+)(?::topic:(\d+))?$/);
+ if (!match) return null;
+ return {
+ chatId: match[1],
+ sourceTopicId: positiveInteger(match[2]),
+ };
+}
+
+export function expectsTelegramPublication(toolContext = {}) {
+ const deliveryChannel = String(toolContext.deliveryContext?.channel ?? "").toLowerCase();
+ const messageChannel = String(toolContext.messageChannel ?? "").toLowerCase();
+ return deliveryChannel === "telegram"
+ || messageChannel === "telegram"
+ || telegramOriginFromSessionKey(toolContext.sessionKey) !== null;
+}
+
+export function resolveTelegramPublicationTarget(toolContext = {}, params = {}) {
+ const explicit = telegramOriginFromDestination(params.telegramChatId);
+ const delivery = String(toolContext.deliveryContext?.channel ?? "").toLowerCase() === "telegram"
+ ? telegramOriginFromDestination(toolContext.deliveryContext?.to)
+ : null;
+ const session = telegramOriginFromSessionKey(toolContext.sessionKey);
+ const chatId = explicit?.chatId ?? delivery?.chatId ?? session?.chatId;
+ if (!chatId) return null;
+ const sourceTopicId = positiveInteger(params.telegramSourceTopicId)
+ ?? positiveInteger(toolContext.deliveryContext?.threadId)
+ ?? explicit?.sourceTopicId
+ ?? delivery?.sourceTopicId
+ ?? session?.sourceTopicId
+ ?? null;
+ return { chatId: String(chatId), sourceTopicId };
+}
diff --git a/server/linux/openclaw-plugin/tool-result.js b/server/linux/openclaw-plugin/tool-result.js
new file mode 100644
index 0000000..9b849cc
--- /dev/null
+++ b/server/linux/openclaw-plugin/tool-result.js
@@ -0,0 +1,7 @@
+export function toolResult(details) {
+ const payload = { ...details, ok: true };
+ return {
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
+ details: payload,
+ };
+}
diff --git a/server/linux/openclaw-plugin/tool-result.test.js b/server/linux/openclaw-plugin/tool-result.test.js
new file mode 100644
index 0000000..065cfcd
--- /dev/null
+++ b/server/linux/openclaw-plugin/tool-result.test.js
@@ -0,0 +1,11 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { toolResult } from "./tool-result.js";
+
+test("successful terminal TaskFlow status is an affirmative tool result", () => {
+ const result = toolResult({ flowId: "flow-1", status: "succeeded" });
+ assert.equal(result.details.ok, true);
+ assert.equal(result.details.status, "succeeded");
+ assert.equal(JSON.parse(result.content[0].text).ok, true);
+});
diff --git a/server/linux/openclaw-plugin/worker-scope.js b/server/linux/openclaw-plugin/worker-scope.js
new file mode 100644
index 0000000..11b5cf8
--- /dev/null
+++ b/server/linux/openclaw-plugin/worker-scope.js
@@ -0,0 +1,54 @@
+import { spawnSync } from "node:child_process";
+
+const FLOW_ID_PATTERN = /^[a-zA-Z0-9-]{1,80}$/;
+
+export function workerScopeUnitName(flowId) {
+ if (typeof flowId !== "string" || !FLOW_ID_PATTERN.test(flowId)) {
+ throw new Error("Unsupported video TaskFlow id for a systemd scope.");
+ }
+ return `openclaw-video-${flowId}.scope`;
+}
+
+export function workerScopeArgs(unitName, command, args = []) {
+ if (typeof unitName !== "string" || !unitName.endsWith(".scope")) {
+ throw new Error("Video worker unit must be a systemd scope.");
+ }
+ if (typeof command !== "string" || !command.startsWith("/")) {
+ throw new Error("Video worker command must be an absolute path.");
+ }
+ return [
+ "--user",
+ "--scope",
+ "--quiet",
+ `--unit=${unitName}`,
+ "--property=CPUWeight=50",
+ "--property=CPUQuota=300%",
+ "--property=IOWeight=50",
+ "--property=MemoryHigh=3G",
+ "--property=MemoryMax=4G",
+ "--property=TasksMax=256",
+ "--",
+ "/usr/bin/nice",
+ "-n",
+ "10",
+ command,
+ ...args,
+ ];
+}
+
+export function isWorkerScopeActive(unitName, run = spawnSync) {
+ if (typeof unitName !== "string" || !unitName.endsWith(".scope")) return false;
+ const result = run("/usr/bin/systemctl", ["--user", "is-active", "--quiet", unitName], {
+ stdio: "ignore",
+ timeout: 5000,
+ });
+ return result.status === 0;
+}
+export function stopWorkerScope(unitName, run = spawnSync) {
+ if (typeof unitName !== "string" || !unitName.endsWith(".scope")) return false;
+ const result = run("/usr/bin/systemctl", ["--user", "stop", unitName], {
+ stdio: "ignore",
+ timeout: 10000,
+ });
+ return result.status === 0;
+}
diff --git a/server/linux/openclaw-plugin/worker-scope.test.js b/server/linux/openclaw-plugin/worker-scope.test.js
new file mode 100644
index 0000000..2c7459d
--- /dev/null
+++ b/server/linux/openclaw-plugin/worker-scope.test.js
@@ -0,0 +1,53 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isWorkerScopeActive,
+ stopWorkerScope,
+ workerScopeArgs,
+ workerScopeUnitName,
+} from "./worker-scope.js";
+
+test("video worker scope is owner-flow specific and resource friendly", () => {
+ const unit = workerScopeUnitName("4e090d8d-5f12-4b32-aa42-985608f3c755");
+ assert.equal(unit, "openclaw-video-4e090d8d-5f12-4b32-aa42-985608f3c755.scope");
+ assert.deepEqual(workerScopeArgs(unit, "/usr/bin/python3", ["/opt/pipeline.py"]), [
+ "--user",
+ "--scope",
+ "--quiet",
+ `--unit=${unit}`,
+ "--property=CPUWeight=50",
+ "--property=CPUQuota=300%",
+ "--property=IOWeight=50",
+ "--property=MemoryHigh=3G",
+ "--property=MemoryMax=4G",
+ "--property=TasksMax=256",
+ "--",
+ "/usr/bin/nice",
+ "-n",
+ "10",
+ "/usr/bin/python3",
+ "/opt/pipeline.py",
+ ]);
+});
+
+test("scope probes and stops use exact non-shell systemctl arguments", () => {
+ const calls = [];
+ const run = (command, args, options) => {
+ calls.push({ command, args, options });
+ return { status: 0 };
+ };
+ const unit = workerScopeUnitName("flow-1");
+ assert.equal(isWorkerScopeActive(unit, run), true);
+ assert.equal(stopWorkerScope(unit, run), true);
+ assert.deepEqual(calls.map((call) => call.args), [
+ ["--user", "is-active", "--quiet", unit],
+ ["--user", "stop", unit],
+ ]);
+});
+
+test("unsafe flow ids and commands are rejected", () => {
+ assert.throws(() => workerScopeUnitName("flow/../../escape"), /Unsupported/);
+ assert.throws(() => workerScopeArgs("worker.service", "/usr/bin/python3"), /scope/);
+ assert.throws(() => workerScopeArgs("worker.scope", "python3"), /absolute/);
+});
diff --git a/server/linux/requirements-stt.txt b/server/linux/requirements-stt.txt
new file mode 100644
index 0000000..0059dd2
--- /dev/null
+++ b/server/linux/requirements-stt.txt
@@ -0,0 +1,9 @@
+--extra-index-url https://download.pytorch.org/whl/cu124
+torch==2.6.0+cu124
+torchaudio==2.6.0+cu124
+gigaam==0.2.0
+openai-whisper==20250625
+imageio-ffmpeg==0.6.0
+numpy==2.5.2
+soundfile==0.14.0
+huggingface-hub==0.36.2
diff --git a/server/linux/requirements-video.txt b/server/linux/requirements-video.txt
new file mode 100644
index 0000000..048de1a
--- /dev/null
+++ b/server/linux/requirements-video.txt
@@ -0,0 +1,11 @@
+--extra-index-url https://download.pytorch.org/whl/cu128
+torch==2.8.0+cu128
+torchaudio==2.8.0+cu128
+qwen-asr==0.0.6
+pyannote.audio==4.0.7
+soundfile==0.14.0
+huggingface-hub==0.36.2
+numpy==2.5.2
+transformers==4.57.6
+accelerate==1.12.0
+yt-dlp==2026.8.19
diff --git a/server/linux/stt/server.py b/server/linux/stt/server.py
new file mode 100644
index 0000000..03643fc
--- /dev/null
+++ b/server/linux/stt/server.py
@@ -0,0 +1,362 @@
+#!/usr/bin/env python3
+"""Resident local speech-to-text service for OpenClaw.
+
+GigaAM v3 E2E RNNT is the primary Russian recognizer. Whisper Turbo is
+loaded only when explicitly requested or when the primary recognizer fails.
+The HTTP endpoint is loopback-only; OpenClaw calls it through transcribe.py.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import stat
+import subprocess
+import tempfile
+import threading
+import time
+import traceback
+import wave
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+GIGAAM_MODEL = "v3_e2e_rnnt"
+MAX_INPUT_BYTES = 50 * 1024 * 1024
+MAX_DURATION_SECONDS = 30 * 60
+REQUEST_TIMEOUT_SECONDS = 180
+
+
+def _duration(path: Path) -> float:
+ with wave.open(str(path), "rb") as audio:
+ return audio.getnframes() / float(audio.getframerate())
+
+
+def _split_wav_for_gigaam(
+ source: Path,
+ *,
+ maximum_seconds: float = 22.0,
+) -> tuple[list[Path], tempfile.TemporaryDirectory[str] | None]:
+ """Split long PCM WAV files near a low-energy point.
+
+ GigaAM's short-form transcribe API is limited to roughly 25 seconds. The
+ VoiceSwitch worker uses the same strategy, with a little safety margin.
+ """
+
+ with wave.open(str(source), "rb") as audio:
+ parameters = audio.getparams()
+ frame_rate = audio.getframerate()
+ channels = audio.getnchannels()
+ sample_width = audio.getsampwidth()
+ frame_count = audio.getnframes()
+ frames = audio.readframes(frame_count)
+
+ if frame_count / float(frame_rate) <= 24.0:
+ return [source], None
+ if channels != 1 or sample_width != 2:
+ raise ValueError("Ожидается mono PCM WAV 16-bit после нормализации.")
+
+ import numpy as np
+
+ samples = np.frombuffer(frames, dtype=" maximum:
+ hard_end = min(total, start + maximum)
+ search_start = max(start + minimum, hard_end - search_span)
+ best_end = hard_end
+ best_energy = float("inf")
+ for candidate in range(search_start, hard_end, analysis_window):
+ segment = samples[candidate : min(candidate + analysis_window, hard_end)]
+ if segment.size == 0:
+ continue
+ energy = float(np.mean(np.abs(segment.astype(np.float32))))
+ if energy < best_energy:
+ best_energy = energy
+ best_end = candidate + max(1, segment.size // 2)
+ boundaries.append(best_end)
+ start = best_end
+ boundaries.append(total)
+
+ temporary = tempfile.TemporaryDirectory(prefix="mitim-stt-gigaam-")
+ chunks: list[Path] = []
+ for index, (left, right) in enumerate(zip(boundaries, boundaries[1:])):
+ output = Path(temporary.name) / f"chunk-{index:03d}.wav"
+ with wave.open(str(output), "wb") as chunk:
+ chunk.setnchannels(1)
+ chunk.setsampwidth(2)
+ chunk.setframerate(frame_rate)
+ chunk.writeframes(samples[left:right].astype(" None:
+ self.cache_root = cache_root
+ self.work_root = work_root
+ self.work_root.mkdir(parents=True, exist_ok=True)
+ self._gpu_lock = threading.Lock()
+ self._whisper_lock = threading.Lock()
+ self.gigaam: Any = None
+ self.whisper: Any = None
+ self.device = "cpu"
+
+ os.environ.setdefault("HF_HOME", str(cache_root / "huggingface"))
+ os.environ.setdefault("TORCH_HOME", str(cache_root / "torch"))
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
+
+ # GigaAM's Hugging Face modeling file invokes ``ffmpeg`` by name.
+ # imageio-ffmpeg supplies a pinned Linux binary inside the venv, so
+ # expose that binary to both our normalizer and the model code.
+ import imageio_ffmpeg
+
+ ffmpeg_executable = Path(imageio_ffmpeg.get_ffmpeg_exe())
+ # GigaAM invokes the executable by the literal name ``ffmpeg``.
+ # imageio-ffmpeg ships a pinned binary with a versioned filename, so
+ # expose it through a private service-local symlink rather than
+ # changing the system installation.
+ ffmpeg_bin = self.work_root.parent / "bin"
+ ffmpeg_bin.mkdir(parents=True, exist_ok=True)
+ ffmpeg_link = ffmpeg_bin / "ffmpeg"
+ if not ffmpeg_link.exists():
+ ffmpeg_link.symlink_to(ffmpeg_executable)
+ os.environ["PATH"] = f"{ffmpeg_bin}{os.pathsep}{os.environ.get('PATH', '')}"
+
+ import torch
+
+ if not torch.cuda.is_available():
+ raise RuntimeError("CUDA недоступна: GPU-режим mitim-stt не активирован.")
+ self.device = "cuda"
+
+ import gigaam
+
+ print("Loading GigaAM v3 E2E RNNT on CUDA", flush=True)
+ self.gigaam = gigaam.load_model(
+ GIGAAM_MODEL,
+ device=self.device,
+ fp16_encoder=True,
+ download_root=str(cache_root / "gigaam"),
+ )
+ print("GigaAM ready", flush=True)
+
+ def _normalize(self, source: Path, temporary: Path) -> Path:
+ import imageio_ffmpeg
+
+ output = temporary / "normalized.wav"
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
+ command = [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ str(source),
+ "-vn",
+ "-ac",
+ "1",
+ "-ar",
+ "16000",
+ "-c:a",
+ "pcm_s16le",
+ str(output),
+ ]
+ completed = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=90,
+ check=False,
+ )
+ if completed.returncode != 0 or not output.exists():
+ detail = (completed.stderr or "ffmpeg не создал WAV").strip()
+ raise RuntimeError(f"Не удалось подготовить аудио: {detail[-500:]}")
+ return output
+
+ def _gigaam_transcribe(self, audio: Path) -> str:
+ chunks, temporary = _split_wav_for_gigaam(audio)
+ try:
+ texts: list[str] = []
+ for chunk in chunks:
+ result = self.gigaam.transcribe(str(chunk))
+ text = getattr(result, "text", str(result)).strip()
+ if text:
+ texts.append(text)
+ return " ".join(texts).strip()
+ finally:
+ if temporary is not None:
+ temporary.cleanup()
+
+ def _load_whisper(self) -> Any:
+ if self.whisper is not None:
+ return self.whisper
+ with self._whisper_lock:
+ if self.whisper is None:
+ import whisper
+
+ print("Loading Whisper Turbo fallback on CUDA", flush=True)
+ self.whisper = whisper.load_model(
+ "turbo",
+ device=self.device,
+ download_root=str(self.cache_root / "whisper"),
+ )
+ print("Whisper fallback ready", flush=True)
+ return self.whisper
+
+ def _whisper_transcribe(self, audio: Path) -> tuple[str, str | None]:
+ model = self._load_whisper()
+ result = model.transcribe(
+ str(audio),
+ task="transcribe",
+ language=None,
+ temperature=0.0,
+ condition_on_previous_text=False,
+ word_timestamps=False,
+ fp16=True,
+ verbose=False,
+ )
+ return str(result.get("text", "")).strip(), result.get("language")
+
+ def transcribe(self, source: Path, engine: str = "auto") -> dict[str, Any]:
+ try:
+ source = source.resolve(strict=True)
+ metadata = source.stat()
+ except FileNotFoundError as error:
+ raise ValueError(f"Аудиофайл не найден: {source}") from error
+ if not stat.S_ISREG(metadata.st_mode):
+ raise ValueError(f"Аудиофайл должен быть обычным файлом: {source}")
+ if metadata.st_size < 1024:
+ raise ValueError("Аудиофайл слишком мал.")
+ if metadata.st_size > MAX_INPUT_BYTES:
+ raise ValueError("Аудиофайл больше 50 МБ.")
+
+ started = time.perf_counter()
+ with self._gpu_lock:
+ with tempfile.TemporaryDirectory(prefix="mitim-stt-request-", dir=self.work_root) as work:
+ normalized = self._normalize(source, Path(work))
+ duration = _duration(normalized)
+ if duration <= 0 or duration > MAX_DURATION_SECONDS:
+ raise ValueError("Длительность аудио должна быть от 0 до 30 минут.")
+
+ if engine == "whisper":
+ text, language = self._whisper_transcribe(normalized)
+ used_engine = "whisper"
+ else:
+ try:
+ text = self._gigaam_transcribe(normalized)
+ language = "ru"
+ used_engine = "gigaam"
+ except Exception:
+ if engine == "gigaam":
+ raise
+ traceback.print_exc()
+ text, language = self._whisper_transcribe(normalized)
+ used_engine = "whisper-fallback"
+
+ if engine == "auto" and not text:
+ text, language = self._whisper_transcribe(normalized)
+ used_engine = "whisper-fallback-empty"
+
+ return {
+ "text": text,
+ "language": language,
+ "engine": used_engine,
+ "duration": duration,
+ "latency": time.perf_counter() - started,
+ }
+
+
+class RequestHandler(BaseHTTPRequestHandler):
+ service: "STTHTTPServer"
+
+ def log_message(self, format: str, *args: Any) -> None:
+ print(f"{self.address_string()} - {format % args}", flush=True)
+
+ def _json(self, status: int, payload: dict[str, Any]) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ if self.path != "/health":
+ self._json(404, {"error": "not found"})
+ return
+ self._json(
+ 200,
+ {"ok": True, "ready": True, "device": self.service.engine.device},
+ )
+
+ def do_POST(self) -> None:
+ if self.path != "/transcribe":
+ self._json(404, {"error": "not found"})
+ return
+ try:
+ content_length = int(self.headers.get("Content-Length", "0"))
+ if content_length <= 0 or content_length > 64 * 1024:
+ raise ValueError("Некорректный размер JSON-запроса.")
+ request = json.loads(self.rfile.read(content_length))
+ source = Path(str(request.get("path", ""))).expanduser()
+ if not source.is_absolute():
+ raise ValueError("Путь к аудио должен быть абсолютным.")
+ engine = str(request.get("engine", "auto"))
+ if engine not in {"auto", "gigaam", "whisper"}:
+ raise ValueError("engine должен быть auto, gigaam или whisper.")
+ if not self.service.admission.acquire(blocking=False):
+ self._json(429, {"error": "STT queue is full"})
+ return
+ try:
+ result = self.service.engine.transcribe(source, engine)
+ finally:
+ self.service.admission.release()
+ self._json(200, result)
+ except Exception as error:
+ traceback.print_exc()
+ self._json(400, {"error": str(error)})
+
+
+class STTHTTPServer(ThreadingHTTPServer):
+ daemon_threads = True
+
+ def __init__(self, address: tuple[str, int], engine: SpeechEngine) -> None:
+ super().__init__(address, RequestHandler)
+ self.engine = engine
+ self.admission = threading.BoundedSemaphore(2)
+ RequestHandler.service = self
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--cache", type=Path, required=True)
+ parser.add_argument("--work", type=Path, required=True)
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=18790)
+ args = parser.parse_args()
+
+ args.cache.mkdir(parents=True, exist_ok=True)
+ args.work.mkdir(parents=True, exist_ok=True)
+ service = SpeechEngine(args.cache, args.work)
+ server = STTHTTPServer((args.host, args.port), service)
+ print(f"mitim-stt listening on http://{args.host}:{args.port}", flush=True)
+ try:
+ server.serve_forever(poll_interval=0.5)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/stt/transcribe.py b/server/linux/stt/transcribe.py
new file mode 100644
index 0000000..d4f39a7
--- /dev/null
+++ b/server/linux/stt/transcribe.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""OpenClaw CLI adapter: print only the transcript to stdout."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+AUDIO_SUFFIXES = {".oga", ".ogg", ".opus", ".mp3", ".m4a", ".wav", ".webm"}
+
+
+def resolve_audio_path(raw: str) -> Path:
+ """Resolve OpenClaw's path and tolerate its empty-path fallback."""
+ candidate = Path(os.path.abspath(os.path.expanduser(raw)))
+ if candidate.is_file():
+ return candidate
+
+ # Some Telegram preflight paths arrive as an empty CLI argument. In that
+ # case abspath() becomes the gateway working directory. Limit
+ # the recovery search to OpenClaw's own inbound media directories and only
+ # accept files written very recently.
+ if candidate.is_dir():
+ roots = [
+ Path.home() / ".openclaw" / "media" / "inbound",
+ Path.home() / ".openclaw" / "workspace" / "media" / "inbound",
+ ]
+ deadline = time.time() - 300
+ recent = [
+ path
+ for root in roots
+ if root.is_dir()
+ for path in root.rglob("*")
+ if path.is_file()
+ and path.suffix.lower() in AUDIO_SUFFIXES
+ and path.stat().st_mtime >= deadline
+ ]
+ if recent:
+ return max(recent, key=lambda path: path.stat().st_mtime)
+ return candidate
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("usage: mitim-stt-transcribe AUDIO_PATH", file=sys.stderr)
+ return 2
+
+ request = urllib.request.Request(
+ os.environ.get("MITIM_STT_URL", "http://127.0.0.1:18790/transcribe"),
+ data=json.dumps(
+ {
+ "path": str(resolve_audio_path(sys.argv[1])),
+ "engine": os.environ.get("MITIM_STT_ENGINE", "auto"),
+ }
+ ).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=180) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as error:
+ print(f"mitim-stt request failed: {error}", file=sys.stderr)
+ return 1
+
+ if payload.get("error"):
+ print(str(payload["error"]), file=sys.stderr)
+ return 1
+ text = str(payload.get("text", "")).strip()
+ if text:
+ sys.stdout.write(text)
+ sys.stdout.flush()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/systemd/mitim-stt.service.in b/server/linux/systemd/mitim-stt.service.in
new file mode 100644
index 0000000..dd64b94
--- /dev/null
+++ b/server/linux/systemd/mitim-stt.service.in
@@ -0,0 +1,19 @@
+[Unit]
+Description=VoiceSwitch resident local speech recognition
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=@STT_ROOT@
+ExecStart=@STT_ROOT@/venv/bin/python @STT_ROOT@/server.py --cache @CACHE_ROOT@ --work @STT_ROOT@/work --host 127.0.0.1 --port 18790
+Restart=on-failure
+RestartSec=5
+TimeoutStartSec=900
+Environment=CUDA_VISIBLE_DEVICES=0
+Environment=PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
+Environment=HF_HOME=@CACHE_ROOT@/huggingface
+Environment=TORCH_HOME=@CACHE_ROOT@/torch
+
+[Install]
+WantedBy=default.target
diff --git a/server/linux/tests/test_enrich_ollama.py b/server/linux/tests/test_enrich_ollama.py
new file mode 100644
index 0000000..8c4d49d
--- /dev/null
+++ b/server/linux/tests/test_enrich_ollama.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+
+import json
+import unittest
+from unittest.mock import patch
+
+from enrich_ollama import (
+ OllamaClient,
+ SUMMARY_SCHEMA,
+ generate_topic_title,
+ normalize_topic_title,
+ reduce_summaries,
+ summarize_chunk,
+)
+
+
+class FakeResponse:
+ def __init__(self, content: str) -> None:
+ self.payload = json.dumps({"message": {"content": content}}, ensure_ascii=False).encode("utf-8")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, traceback):
+ return False
+
+ def read(self) -> bytes:
+ return self.payload
+
+
+class BrokenClient:
+ model = "broken-model"
+
+ def chat(self, prompt, *, schema, images=None):
+ raise ValueError("невалидный JSON")
+
+
+class EnrichOllamaTests(unittest.TestCase):
+ def test_client_retries_invalid_json(self) -> None:
+ responses = iter(
+ [
+ FakeResponse("это не JSON"),
+ FakeResponse(
+ json.dumps(
+ {
+ "chapter_title": "Тема",
+ "overview": "Обзор",
+ "key_points": [],
+ "decisions": [],
+ "action_items": [],
+ "risks": [],
+ },
+ ensure_ascii=False,
+ )
+ ),
+ ]
+ )
+ calls = []
+
+ def fake_urlopen(request, timeout):
+ calls.append(json.loads(request.data.decode("utf-8")))
+ return next(responses)
+
+ client = OllamaClient(url="http://ollama.test", model="test", json_attempts=2)
+ with patch("enrich_ollama.urlopen", side_effect=fake_urlopen):
+ result = client.chat("Сделай саммари", schema=SUMMARY_SCHEMA)
+
+ self.assertEqual(result["chapter_title"], "Тема")
+ self.assertEqual(len(calls), 2)
+ self.assertEqual(len(calls[1]["messages"]), 3)
+
+ def test_chunk_uses_deterministic_fallback(self) -> None:
+ result = summarize_chunk(
+ {
+ "start": 0,
+ "end": 5,
+ "text": "[0:00] Первая мысль.\n[0:03] Вторая мысль.",
+ },
+ client=BrokenClient(),
+ )
+
+ self.assertTrue(result["_fallback"])
+ self.assertIn("Первая мысль", result["overview"])
+ self.assertEqual(result["key_points"], ["Первая мысль.", "Вторая мысль."])
+
+ def test_reduce_uses_deterministic_fallback(self) -> None:
+ items = [
+ {
+ "start": 0,
+ "end": 5,
+ "overview": "Первая часть.",
+ "key_points": ["Тезис 1"],
+ "decisions": [],
+ "action_items": [],
+ "risks": [],
+ },
+ {
+ "start": 5,
+ "end": 10,
+ "overview": "Вторая часть.",
+ "key_points": ["Тезис 2"],
+ "decisions": [],
+ "action_items": [],
+ "risks": [],
+ },
+ ]
+
+ result = reduce_summaries(items, client=BrokenClient())
+
+ self.assertTrue(result["_fallback"])
+ self.assertEqual(result["key_points"], ["Тезис 1", "Тезис 2"])
+
+ def test_topic_title_is_short_and_semantic(self) -> None:
+ title = normalize_topic_title("🎬 Проверка сайта к первому сентября 2026")
+ self.assertEqual(title, "Проверка сайта к первому сентября 2026")
+ self.assertLessEqual(len(title.split()), 8)
+ self.assertLessEqual(len(title), 56)
+
+ def test_topic_title_rejects_generic_and_file_names(self) -> None:
+ with self.assertRaises(ValueError):
+ normalize_topic_title("Видео")
+ with self.assertRaises(ValueError):
+ normalize_topic_title("camera-upload-1234.mp4")
+
+ def test_topic_title_has_deterministic_summary_fallback(self) -> None:
+ title = generate_topic_title(
+ {"overview": "Подробная проверка готовности сайта.", "key_points": []},
+ [{"chapter_title": "Проверка сайта к первому сентября"}],
+ source_title="camera-upload-1234.mp4",
+ client=BrokenClient(),
+ )
+ self.assertEqual(title, "Проверка сайта к первому сентября")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/server/linux/tests/test_release_contract.py b/server/linux/tests/test_release_contract.py
new file mode 100644
index 0000000..35a6287
--- /dev/null
+++ b/server/linux/tests/test_release_contract.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class LinuxServerReleaseContractTests(unittest.TestCase):
+ def test_installed_versions_are_pinned(self) -> None:
+ stt = (ROOT / "requirements-stt.txt").read_text(encoding="utf-8")
+ video = (ROOT / "requirements-video.txt").read_text(encoding="utf-8")
+ for expected in (
+ "torch==2.6.0+cu124",
+ "torchaudio==2.6.0+cu124",
+ "gigaam==0.2.0",
+ "openai-whisper==20250625",
+ ):
+ self.assertIn(expected, stt)
+ for expected in (
+ "torch==2.8.0+cu128",
+ "torchaudio==2.8.0+cu128",
+ "qwen-asr==0.0.6",
+ "pyannote.audio==4.0.7",
+ "transformers==4.57.6",
+ "accelerate==1.12.0",
+ ):
+ self.assertIn(expected, video)
+
+ def test_service_is_loopback_only(self) -> None:
+ unit = (ROOT / "systemd" / "mitim-stt.service.in").read_text(
+ encoding="utf-8"
+ )
+ self.assertIn("--host 127.0.0.1 --port 18790", unit)
+
+ def test_private_server_values_are_not_published(self) -> None:
+ forbidden = (
+ "/home/" + "ai",
+ "-100" + "4497951319",
+ "bot:" + "mitim_" + "openclaw",
+ )
+ for path in ROOT.rglob("*"):
+ if not path.is_file() or path.suffix in {".pyc"}:
+ continue
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ for value in forbidden:
+ self.assertNotIn(value, text, f"{value!r} leaked through {path}")
+
+ def test_pipeline_and_plugin_versions_match_snapshot(self) -> None:
+ pipeline = (ROOT / "video" / "video_pipeline.py").read_text(
+ encoding="utf-8"
+ )
+ plugin = (ROOT / "openclaw-plugin" / "openclaw.plugin.json").read_text(
+ encoding="utf-8"
+ )
+ self.assertIn('PIPELINE_VERSION = "mitim-video-pipeline-v3"', pipeline)
+ self.assertIn('"version": "0.1.6"', plugin)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/server/linux/tests/test_telegram_publication.py b/server/linux/tests/test_telegram_publication.py
new file mode 100644
index 0000000..3c63135
--- /dev/null
+++ b/server/linux/tests/test_telegram_publication.py
@@ -0,0 +1,207 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from telegram_publication import (
+ SAFE_TEXT_LIMIT,
+ action_descriptors,
+ build_publication_plan,
+ record_message,
+ record_topic,
+)
+
+
+def write_json(path: Path, value: dict) -> None:
+ path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
+
+
+class TelegramPublicationTests(unittest.TestCase):
+ def make_artifacts(self, root: Path, *, youtube: bool = False, long_overview: bool = False) -> Path:
+ source = (
+ {"kind": "youtube", "canonical_url": "https://youtu.be/abc123", "original": "https://youtu.be/abc123"}
+ if youtube
+ else {"kind": "telegram", "original": "/private/incoming/video.mp4", "sha256": "a" * 64}
+ )
+ write_json(
+ root / "transcript.json",
+ {
+ "asset_id": "asset-1",
+ "title": "Тестовое видео",
+ "duration": 125,
+ "profile": "deep",
+ "asr": {"engines_used": ["gigaam"]},
+ "segments": [],
+ },
+ )
+ write_json(
+ root / "manifest.json",
+ {
+ "asset_id": "asset-1",
+ "title": "Тестовое видео",
+ "profile": "deep",
+ "duration": 125,
+ "qc_status": "ok",
+ "vision_status": "complete",
+ "summary_status": "complete",
+ },
+ )
+ write_json(
+ root / "summary.json",
+ {
+ "title": "Практическая проверка сайта к празднику",
+ "overview": "я" * 5000 if long_overview else "Краткое содержание.",
+ "key_points": ["Первый тезис"],
+ "chapters": [{"start": 65, "title": "Главный фрагмент"}],
+ "risks": ["Проверить выводы"],
+ },
+ )
+ write_json(root / "metadata.json", {"channel": "Тестовый канал"})
+ write_json(root / "source-info.json", source)
+ write_json(root / "qc.json", {"status": "ok"})
+ write_json(
+ root / "job-state.json",
+ {
+ "profile": "deep",
+ "stages": {
+ "alignment": {"status": "complete"},
+ "diarization": {"status": "complete"},
+ "visuals": {"status": "complete"},
+ "summary": {"status": "complete"},
+ },
+ },
+ )
+ (root / "summary.md").write_text("summary", encoding="utf-8")
+ (root / "transcript.md").write_text("transcript", encoding="utf-8")
+ return root
+
+ def test_build_is_idempotent_and_does_not_publish_local_path(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ first = build_publication_plan(root, chat_id="-1001234567890", source_topic_id=7)
+ second = build_publication_plan(root, chat_id="-1001234567890", source_topic_id=7)
+ self.assertEqual(first["topic"]["name"], "🎬 Практическая проверка сайта к празднику")
+ self.assertLessEqual(len(first["topic"]["name"]), 60)
+ self.assertNotIn("/private/incoming", first["content"]["summary"])
+ self.assertEqual(first["topic"]["idempotency_key"], second["topic"]["idempotency_key"])
+ self.assertEqual(2, len(first["attachments"]))
+
+ def test_youtube_timestamp_and_message_limit(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory), youtube=True)
+ plan = build_publication_plan(root, chat_id=-1001234567890)
+ self.assertIn("https://youtu.be/abc123?t=65", plan["content"]["summary"])
+ write_json(
+ root / "summary.json",
+ {"title": "Практическая проверка сайта к празднику", "overview": "я" * 5000},
+ )
+ plan = build_publication_plan(root, chat_id=-1001234567890)
+ self.assertLessEqual(len(plan["content"]["summary"]), SAFE_TEXT_LIMIT)
+ self.assertIn("Текст сокращён", plan["content"]["summary"])
+
+ def test_internal_stage_error_is_not_published(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ write_json(
+ root / "job-state.json",
+ {
+ "profile": "deep",
+ "stages": {
+ "visuals": {
+ "status": "partial",
+ "error": 'Traceback: File "/home/private/enrich.py" RuntimeError: Ollama failed',
+ }
+ },
+ },
+ )
+ plan = build_publication_plan(root, chat_id=-1001234567890)
+ summary = plan["content"]["summary"]
+ self.assertIn("OCR/vision: partial", summary)
+ self.assertNotIn("Traceback", summary)
+ self.assertNotIn("/home/private", summary)
+
+ def test_idempotency_keys_follow_payload_content(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ first_plan = build_publication_plan(root, chat_id=-1001234567890)
+ first = {item["kind"]: item["idempotency_key"] for item in action_descriptors(first_plan, topic_id=777)}
+
+ write_json(
+ root / "summary.json",
+ {"title": "Практическая проверка сайта к празднику", "overview": "Новая версия."},
+ )
+ (root / "summary.md").write_text("updated summary", encoding="utf-8")
+ second_plan = build_publication_plan(root, chat_id=-1001234567890)
+ second = {
+ item["kind"]: item["idempotency_key"]
+ for item in action_descriptors(second_plan, topic_id=777)
+ }
+
+ self.assertEqual(first["status"], second["status"])
+ self.assertEqual(first["transcript_document"], second["transcript_document"])
+ self.assertNotEqual(first["summary"], second["summary"])
+ self.assertNotEqual(first["summary_document"], second["summary_document"])
+
+ def test_actions_resume_without_duplicates(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ plan_path = root / "telegram-publication.json"
+ plan = build_publication_plan(root, chat_id=-1001234567890)
+ topic_action = action_descriptors(plan)
+ self.assertEqual("create_forum_topic", topic_action[0]["operation"])
+ self.assertTrue(topic_action[0]["owner_direct_approval"])
+ record_topic(plan_path, 777)
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
+ actions = action_descriptors(plan)
+ self.assertEqual({"status", "summary", "summary_document", "transcript_document"}, {a["kind"] for a in actions})
+ self.assertTrue(all(action["owner_direct_approval"] for action in actions))
+ for index, action in enumerate(actions, start=100):
+ record_message(plan_path, action["kind"], index)
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
+ self.assertEqual("completed", plan["delivery"]["status"])
+ self.assertEqual([], action_descriptors(plan))
+
+ def test_required_publication_documents_must_exist(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ (root / "summary.md").unlink()
+ with self.assertRaisesRegex(FileNotFoundError, "обязательный файл публикации"):
+ build_publication_plan(root, chat_id=-1001234567890)
+
+ def test_topic_name_must_be_semantic_and_come_from_summary(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ write_json(root / "summary.json", {"title": "video-1234.mp4", "overview": "Содержание."})
+ with self.assertRaisesRegex(ValueError, "именем файла"):
+ build_publication_plan(root, chat_id=-1001234567890)
+
+ write_json(root / "summary.json", {"overview": "Содержание без заголовка."})
+ with self.assertRaisesRegex(ValueError, "отсутствует смысловое название"):
+ build_publication_plan(root, chat_id=-1001234567890)
+
+ def test_legacy_complete_delivery_is_migrated(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = self.make_artifacts(Path(directory))
+ plan_path = root / "telegram-publication.json"
+ build_publication_plan(root, chat_id=-1001234567890)
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
+ plan["delivery"] = {
+ "status": "complete",
+ "topic_id": 777,
+ "messages": {
+ "status": 10,
+ "summary": 11,
+ "summary_document": 12,
+ "transcript_document": 13,
+ },
+ "completed_at": "2026-08-30T07:00:00+00:00",
+ }
+ write_json(plan_path, plan)
+ rebuilt = build_publication_plan(root, chat_id=-1001234567890)
+ self.assertEqual("completed", rebuilt["delivery"]["status"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/server/linux/video/align_qwen.py b/server/linux/video/align_qwen.py
new file mode 100644
index 0000000..1cadec4
--- /dev/null
+++ b/server/linux/video/align_qwen.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Add word timestamps to an existing transcript with Qwen3 ForcedAligner."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+from pathlib import Path
+from typing import Any
+
+import soundfile as sf
+import torch
+from qwen_asr import Qwen3ForcedAligner
+
+
+DEFAULT_MODEL = "Qwen/Qwen3-ForcedAligner-0.6B"
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--audio", required=True, type=Path)
+ parser.add_argument("--transcript", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--model", default=DEFAULT_MODEL)
+ parser.add_argument("--language", default="Russian")
+ parser.add_argument("--device", default="cuda:0")
+ parser.add_argument("--max-seconds", type=float, default=280.0)
+ return parser.parse_args()
+
+
+def read_transcript(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(payload, list):
+ return {"segments": payload}, payload
+ if not isinstance(payload, dict) or not isinstance(payload.get("segments"), list):
+ raise ValueError("Transcript must be a JSON object with a segments array")
+ return payload, payload["segments"]
+
+
+def split_segment(segment: dict[str, Any], max_seconds: float) -> list[dict[str, Any]]:
+ start = float(segment.get("start", 0.0))
+ end = float(segment.get("end", start))
+ text = str(segment.get("text", "")).strip()
+ duration = max(0.0, end - start)
+ if not text or duration <= 0.0:
+ return []
+ count = max(1, math.ceil(duration / max_seconds))
+ if count == 1:
+ return [{"start": start, "end": end, "text": text}]
+
+ words = text.split()
+ units: list[dict[str, Any]] = []
+ for index in range(count):
+ left = math.floor(len(words) * index / count)
+ right = math.floor(len(words) * (index + 1) / count)
+ unit_text = " ".join(words[left:right]).strip()
+ unit_start = start + duration * index / count
+ unit_end = start + duration * (index + 1) / count
+ if unit_text:
+ units.append({"start": unit_start, "end": unit_end, "text": unit_text})
+ return units
+
+
+def read_audio_slice(handle: sf.SoundFile, start: float, end: float) -> Any:
+ sample_rate = int(handle.samplerate)
+ first = max(0, round(start * sample_rate))
+ frames = max(1, round((end - start) * sample_rate))
+ handle.seek(min(first, len(handle)))
+ audio = handle.read(frames=frames, dtype="float32", always_2d=True)
+ if audio.shape[1] > 1:
+ audio = audio.mean(axis=1)
+ else:
+ audio = audio[:, 0]
+ return audio, sample_rate
+
+
+def main() -> int:
+ args = parse_args()
+ payload, segments = read_transcript(args.transcript)
+ if not args.audio.is_file():
+ raise FileNotFoundError(args.audio)
+
+ use_cuda = args.device.startswith("cuda") and torch.cuda.is_available()
+ device = args.device if use_cuda else "cpu"
+ dtype = torch.float16 if use_cuda else torch.float32
+ aligner = Qwen3ForcedAligner.from_pretrained(
+ args.model,
+ device_map=device,
+ dtype=dtype,
+ attn_implementation="sdpa",
+ )
+
+ aligned_segments: list[dict[str, Any]] = []
+ all_words: list[dict[str, Any]] = []
+ errors: list[dict[str, Any]] = []
+
+ with sf.SoundFile(str(args.audio)) as audio_file:
+ for index, original in enumerate(segments):
+ segment = dict(original)
+ segment_words: list[dict[str, Any]] = []
+ try:
+ for unit in split_segment(segment, args.max_seconds):
+ audio = read_audio_slice(audio_file, unit["start"], unit["end"])
+ result = aligner.align(
+ audio=audio,
+ text=unit["text"],
+ language=args.language,
+ )[0]
+ for item in result:
+ word = {
+ "text": item.text,
+ "start": round(unit["start"] + float(item.start_time), 3),
+ "end": round(unit["start"] + float(item.end_time), 3),
+ }
+ segment_words.append(word)
+ all_words.append(word)
+ except Exception as exc: # Keep the canonical GigaAM text on local failures.
+ errors.append({"segment": index, "error": str(exc)})
+
+ if segment_words:
+ segment["start"] = segment_words[0]["start"]
+ segment["end"] = segment_words[-1]["end"]
+ segment["words"] = segment_words
+ segment["alignment_engine"] = "qwen3-forced-aligner"
+ aligned_segments.append(segment)
+
+ result_payload = dict(payload)
+ result_payload["segments"] = aligned_segments
+ result_payload["words"] = all_words
+ result_payload["alignment"] = {
+ "engine": "qwen3-forced-aligner",
+ "model": args.model,
+ "language": args.language,
+ "device": device,
+ "aligned_segments": len(segments) - len(errors),
+ "failed_segments": errors,
+ }
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(
+ json.dumps(result_payload, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/video/diarize_pyannote.py b/server/linux/video/diarize_pyannote.py
new file mode 100644
index 0000000..5d25ee9
--- /dev/null
+++ b/server/linux/video/diarize_pyannote.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Assign pyannote speaker labels to transcript segments and words."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import torch
+import soundfile as sf
+from huggingface_hub import get_token
+from pyannote.audio import Pipeline
+
+
+DEFAULT_MODEL = "pyannote/speaker-diarization-community-1"
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--audio", required=True, type=Path)
+ parser.add_argument("--transcript", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--model", default=DEFAULT_MODEL)
+ parser.add_argument("--device", default="cuda")
+ parser.add_argument("--num-speakers", type=int)
+ parser.add_argument("--min-speakers", type=int)
+ parser.add_argument("--max-speakers", type=int)
+ return parser.parse_args()
+
+
+def load_transcript(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(payload, list):
+ return {"segments": payload}, payload
+ if not isinstance(payload, dict) or not isinstance(payload.get("segments"), list):
+ raise ValueError("Transcript must be a JSON object with a segments array")
+ return payload, payload["segments"]
+
+
+def best_speaker(start: float, end: float, turns: list[dict[str, Any]]) -> str | None:
+ best_label: str | None = None
+ best_overlap = 0.0
+ midpoint = (start + end) / 2.0
+ nearest_distance = float("inf")
+ for turn in turns:
+ overlap = max(0.0, min(end, turn["end"]) - max(start, turn["start"]))
+ if overlap > best_overlap:
+ best_overlap = overlap
+ best_label = turn["speaker"]
+ if best_overlap == 0.0:
+ turn_midpoint = (turn["start"] + turn["end"]) / 2.0
+ distance = abs(midpoint - turn_midpoint)
+ if distance < nearest_distance:
+ nearest_distance = distance
+ best_label = turn["speaker"]
+ return best_label
+
+
+def main() -> int:
+ args = parse_args()
+ token = (
+ os.environ.get("HF_TOKEN")
+ or os.environ.get("HUGGINGFACE_HUB_TOKEN")
+ or get_token()
+ )
+ if not token:
+ raise RuntimeError(
+ "Hugging Face authentication is required after accepting the "
+ "pyannote community-1 model terms"
+ )
+
+ payload, segments = load_transcript(args.transcript)
+ if not args.audio.is_file():
+ raise FileNotFoundError(args.audio)
+
+ pipeline = Pipeline.from_pretrained(args.model, token=token)
+ device = torch.device(args.device if torch.cuda.is_available() else "cpu")
+ pipeline.to(device)
+ call_args = {
+ key: value
+ for key, value in {
+ "num_speakers": args.num_speakers,
+ "min_speakers": args.min_speakers,
+ "max_speakers": args.max_speakers,
+ }.items()
+ if value is not None
+ }
+ samples, sample_rate = sf.read(
+ args.audio,
+ dtype="float32",
+ always_2d=True,
+ )
+ audio = {
+ "waveform": torch.from_numpy(samples.T.copy()),
+ "sample_rate": int(sample_rate),
+ }
+ diarization_output = pipeline(audio, **call_args)
+ annotation = getattr(diarization_output, "speaker_diarization", diarization_output)
+ turns = [
+ {
+ "start": round(float(turn.start), 3),
+ "end": round(float(turn.end), 3),
+ "speaker": str(speaker),
+ }
+ for turn, _, speaker in annotation.itertracks(yield_label=True)
+ ]
+
+ diarized_segments: list[dict[str, Any]] = []
+ for original in segments:
+ segment = dict(original)
+ start = float(segment.get("start", 0.0))
+ end = float(segment.get("end", start))
+ segment["speaker"] = best_speaker(start, end, turns)
+ words = segment.get("words")
+ if isinstance(words, list):
+ labeled_words = []
+ for original_word in words:
+ word = dict(original_word)
+ word_start = float(word.get("start", start))
+ word_end = float(word.get("end", word_start))
+ word["speaker"] = best_speaker(word_start, word_end, turns)
+ labeled_words.append(word)
+ segment["words"] = labeled_words
+ diarized_segments.append(segment)
+
+ result_payload = dict(payload)
+ result_payload["segments"] = diarized_segments
+ result_payload["speaker_turns"] = turns
+ result_payload["speakers"] = sorted({turn["speaker"] for turn in turns})
+ result_payload["diarization"] = {
+ "engine": "pyannote.audio",
+ "model": args.model,
+ "device": str(device),
+ }
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(
+ json.dumps(result_payload, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/video/enrich_ollama.py b/server/linux/video/enrich_ollama.py
new file mode 100644
index 0000000..9c93429
--- /dev/null
+++ b/server/linux/video/enrich_ollama.py
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+"""Local OCR/vision and transcript summarization through Ollama.
+
+The helper is deliberately dependency-free: frames and transcripts stay on the
+server and are sent only to the local Ollama HTTP endpoint.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+
+DEFAULT_MODEL = "qwen3-vl:8b"
+DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434"
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def atomic_write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def atomic_write_text(path: Path, value: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(value, encoding="utf-8")
+ temporary.replace(path)
+
+
+def read_json(path: Path) -> dict[str, Any]:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"Ожидался JSON-объект: {path}")
+ return value
+
+
+def human_timestamp(seconds: float) -> str:
+ total = max(0, int(seconds))
+ hours, remainder = divmod(total, 3600)
+ minutes, secs = divmod(remainder, 60)
+ return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
+
+
+def parse_json_object(value: str) -> dict[str, Any]:
+ text = value.strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"\s*```$", "", text)
+ try:
+ parsed = json.loads(text)
+ except json.JSONDecodeError:
+ start = text.find("{")
+ end = text.rfind("}")
+ if start < 0 or end <= start:
+ raise ValueError("Ollama не вернул JSON-объект")
+ parsed = json.loads(text[start : end + 1])
+ if not isinstance(parsed, dict):
+ raise ValueError("Ollama вернул JSON другого типа")
+ return parsed
+
+
+class OllamaClient:
+ def __init__(self, *, url: str, model: str, timeout: int = 900, json_attempts: int = 3) -> None:
+ self.endpoint = url.rstrip("/") + "/api/chat"
+ self.model = model
+ self.timeout = timeout
+ self.json_attempts = max(1, json_attempts)
+
+ def chat(
+ self,
+ prompt: str,
+ *,
+ schema: dict[str, Any],
+ images: list[Path] | None = None,
+ ) -> dict[str, Any]:
+ message: dict[str, Any] = {"role": "user", "content": prompt}
+ if images:
+ message["images"] = [base64.b64encode(path.read_bytes()).decode("ascii") for path in images]
+ messages = [message]
+ last_error: Exception | None = None
+ for attempt in range(1, self.json_attempts + 1):
+ payload = {
+ "model": self.model,
+ "messages": messages,
+ "stream": False,
+ "think": False,
+ "format": schema,
+ "keep_alive": "10m",
+ "options": {"temperature": 0, "num_ctx": 8192},
+ }
+ request = Request(
+ self.endpoint,
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ try:
+ with urlopen(request, timeout=self.timeout) as response:
+ result = json.loads(response.read().decode("utf-8"))
+ except HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")[-1200:]
+ raise RuntimeError(f"Ollama HTTP {error.code}: {detail}") from error
+ except URLError as error:
+ raise RuntimeError(f"Локальный Ollama недоступен: {error.reason}") from error
+ message_result = result.get("message", {})
+ content = message_result.get("content")
+ if not isinstance(content, str) or not content.strip():
+ content = message_result.get("thinking")
+ if not isinstance(content, str) or not content.strip():
+ last_error = RuntimeError("Ollama вернул пустой ответ")
+ content = ""
+ else:
+ try:
+ return parse_json_object(content)
+ except (ValueError, json.JSONDecodeError) as error:
+ last_error = error
+ if attempt < self.json_attempts:
+ messages.extend(
+ [
+ {"role": "assistant", "content": content},
+ {
+ "role": "user",
+ "content": (
+ "Предыдущий ответ не удалось разобрать как JSON. "
+ "Повтори ответ строго как один валидный JSON-объект по заданной схеме, "
+ "без пояснений и Markdown."
+ ),
+ },
+ ]
+ )
+ raise ValueError(f"Ollama не вернул валидный JSON за {self.json_attempts} попытки") from last_error
+
+
+VISION_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "ocr_text": {"type": "string"},
+ "description": {"type": "string"},
+ "visible_actions": {"type": "array", "items": {"type": "string"}},
+ "visual_type": {
+ "type": "string",
+ "enum": ["slide", "interface", "document", "camera", "diagram", "other"],
+ },
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1},
+ "warnings": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["ocr_text", "description", "visible_actions", "visual_type", "confidence", "warnings"],
+ "additionalProperties": False,
+}
+
+
+SUMMARY_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "chapter_title": {"type": "string"},
+ "overview": {"type": "string"},
+ "key_points": {"type": "array", "items": {"type": "string"}},
+ "decisions": {"type": "array", "items": {"type": "string"}},
+ "action_items": {"type": "array", "items": {"type": "string"}},
+ "risks": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["chapter_title", "overview", "key_points", "decisions", "action_items", "risks"],
+ "additionalProperties": False,
+}
+
+TOPIC_TITLE_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {"title": {"type": "string"}},
+ "required": ["title"],
+ "additionalProperties": False,
+}
+
+GENERIC_TOPIC_TITLES = {
+ "видео",
+ "тестовое видео",
+ "фрагмент транскрипта",
+ "итоговый конспект",
+}
+
+
+def normalize_topic_title(value: Any) -> str:
+ """Return a short semantic topic title without an emoji or technical ids."""
+ rendered = re.sub(r"\s+", " ", str(value or "")).strip(" \t\r\n🎬:—–-.,;!?«»\"'")
+ if not rendered or rendered.casefold() in GENERIC_TOPIC_TITLES:
+ raise ValueError("Название топика не отражает содержание видео")
+ if re.search(r"\.(?:mp4|mov|mkv|webm|avi|mp3|wav|m4a)$", rendered, flags=re.IGNORECASE):
+ raise ValueError("Название топика похоже на имя файла")
+ if re.fullmatch(r"[0-9a-f]{12,64}", rendered, flags=re.IGNORECASE) or re.fullmatch(
+ r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", rendered, flags=re.IGNORECASE
+ ):
+ raise ValueError("Название топика похоже на технический идентификатор")
+
+ words = re.findall(r"[0-9A-Za-zА-Яа-яЁё]+(?:[-–][0-9A-Za-zА-Яа-яЁё]+)*", rendered)
+ if len(words) < 3:
+ raise ValueError("Название топика должно содержать не менее трёх слов")
+ words = words[:8]
+ while len(words) >= 3 and len(" ".join(words)) > 56:
+ words.pop()
+ if len(words) < 3:
+ raise ValueError("Название топика нельзя сократить до 56 символов без потери смысла")
+ return " ".join(words)
+
+
+def generate_topic_title(
+ reduced: dict[str, Any],
+ partials: list[dict[str, Any]],
+ *,
+ source_title: Any,
+ client: OllamaClient,
+) -> str:
+ """Generate the Telegram topic title from the completed transcript summary."""
+ evidence = {
+ "overview": str(reduced.get("overview") or "").strip(),
+ "key_points": unique_strings(list(reduced.get("key_points") or []), limit=8),
+ "chapters": unique_strings(
+ [str(item.get("chapter_title") or "") for item in partials], limit=8
+ ),
+ }
+ prompt = (
+ "Сформулируй короткое фактическое название темы видео по готовому конспекту. "
+ "Верни только JSON. Поле title: 3–8 слов, не более 56 символов, без эмодзи, "
+ "кавычек, имени файла и технических идентификаторов. Название должно отражать "
+ "главную тему или практический результат, не быть кликбейтом.\n\n"
+ + json.dumps(evidence, ensure_ascii=False)
+ )
+ try:
+ result = client.chat(prompt, schema=TOPIC_TITLE_SCHEMA)
+ return normalize_topic_title(result.get("title"))
+ except (RuntimeError, ValueError):
+ candidates = [
+ *(item.get("chapter_title") for item in partials),
+ *(reduced.get("key_points") or []),
+ reduced.get("overview"),
+ source_title,
+ ]
+ for candidate in candidates:
+ try:
+ return normalize_topic_title(candidate)
+ except ValueError:
+ continue
+ raise ValueError("Не удалось сформировать смысловое название топика из транскрипта")
+
+
+def analyze_visual_timeline(
+ timeline_path: Path,
+ *,
+ client: OllamaClient,
+ force: bool,
+) -> dict[str, Any]:
+ timeline = read_json(timeline_path)
+ frames = timeline.get("frames")
+ if not isinstance(frames, list):
+ raise ValueError("В visual-timeline.json отсутствует список frames")
+ for index, frame in enumerate(frames):
+ if not isinstance(frame, dict):
+ raise ValueError(f"Некорректная запись кадра #{index + 1}")
+ if frame.get("status") == "complete" and not force:
+ continue
+ image_path = Path(str(frame.get("path", ""))).expanduser()
+ if not image_path.is_absolute():
+ image_path = image_path.resolve() if image_path.is_file() else (timeline_path.parent / image_path).resolve()
+ if not image_path.is_file():
+ raise FileNotFoundError(f"Не найден кадр: {image_path}")
+ timestamp = float(frame.get("timestamp") or 0)
+ prompt = (
+ "Проанализируй один кадр видео. Верни только JSON по заданной схеме. "
+ "Дословно перепиши весь уверенно читаемый русский и английский текст в ocr_text. "
+ "В ocr_text запрещено копировать слова этой инструкции: записывай только текст, "
+ "физически видимый на изображении; если текста нет, верни пустую строку. "
+ "Кратко опиши только видимое содержимое и действия. Не угадывай скрытый контекст, "
+ "имена людей и события вне кадра. Неразборчивое не выдумывай, а укажи в warnings. "
+ f"Таймкод кадра: {human_timestamp(timestamp)}."
+ )
+ try:
+ result = client.chat(prompt, schema=VISION_SCHEMA, images=[image_path])
+ frame.update(
+ {
+ "ocr": str(result.get("ocr_text") or "").strip() or None,
+ "description": str(result.get("description") or "").strip(),
+ "actions": [str(item).strip() for item in result.get("visible_actions", []) if str(item).strip()],
+ "visual_type": result.get("visual_type", "other"),
+ "confidence": float(result.get("confidence") or 0),
+ "warnings": [str(item).strip() for item in result.get("warnings", []) if str(item).strip()],
+ "model": client.model,
+ "status": "complete",
+ "analyzed_at": utc_now(),
+ }
+ )
+ except Exception as error:
+ frame.update({"status": "failed", "error": str(error), "analyzed_at": utc_now()})
+ timeline.update({"status": "failed", "model": client.model, "updated_at": utc_now()})
+ atomic_write_json(timeline_path, timeline)
+ raise
+ timeline.update({"status": "running", "model": client.model, "updated_at": utc_now()})
+ atomic_write_json(timeline_path, timeline)
+ timeline.update(
+ {
+ "status": "complete",
+ "model": client.model,
+ "frame_count": len(frames),
+ "analyzed_frame_count": sum(frame.get("status") == "complete" for frame in frames),
+ "completed_at": utc_now(),
+ }
+ )
+ atomic_write_json(timeline_path, timeline)
+ return timeline
+
+
+def transcript_chunks(segments: list[dict[str, Any]], *, max_chars: int = 8500) -> list[dict[str, Any]]:
+ chunks: list[dict[str, Any]] = []
+ current: list[str] = []
+ current_start = 0.0
+ current_end = 0.0
+ current_size = 0
+ for segment in segments:
+ text = str(segment.get("text") or "").strip()
+ if not text:
+ continue
+ start = float(segment.get("start") or 0)
+ end = float(segment.get("end") or start)
+ line = f"[{human_timestamp(start)}] {text}"
+ if current and current_size + len(line) + 1 > max_chars:
+ chunks.append({"start": current_start, "end": current_end, "text": "\n".join(current)})
+ current = []
+ current_size = 0
+ if not current:
+ current_start = start
+ current.append(line)
+ current_end = end
+ current_size += len(line) + 1
+ if current:
+ chunks.append({"start": current_start, "end": current_end, "text": "\n".join(current)})
+ return chunks
+
+
+def summarize_chunk(chunk: dict[str, Any], *, client: OllamaClient) -> dict[str, Any]:
+ prompt = (
+ "Сделай фактический конспект фрагмента транскрипта видео. Верни только JSON по схеме. "
+ "Не добавляй знания извне и не исправляй утверждения автора молча. Пустые разделы оставляй "
+ "пустыми массивами. chapter_title должен коротко отражать тему фрагмента.\n\n"
+ f"Диапазон: {human_timestamp(float(chunk['start']))}–{human_timestamp(float(chunk['end']))}\n"
+ f"Транскрипт:\n{chunk['text']}"
+ )
+ try:
+ result = client.chat(prompt, schema=SUMMARY_SCHEMA)
+ except (RuntimeError, ValueError) as error:
+ lines = [
+ re.sub(r"^\[[^\]]+\]\s*", "", line).strip()
+ for line in str(chunk.get("text") or "").splitlines()
+ ]
+ lines = [line for line in lines if line]
+ overview = " ".join(lines).strip()
+ if len(overview) > 1600:
+ overview = overview[:1599].rstrip() + "…"
+ result = {
+ "chapter_title": (lines[0][:120] if lines else "Фрагмент транскрипта"),
+ "overview": overview or "Нет распознанного текста.",
+ "key_points": lines[:8],
+ "decisions": [],
+ "action_items": [],
+ "risks": [],
+ "_fallback": True,
+ "_fallback_reason": str(error),
+ }
+ return {"start": chunk["start"], "end": chunk["end"], **result}
+
+
+def unique_strings(values: list[Any], *, limit: int = 30) -> list[str]:
+ result: list[str] = []
+ seen: set[str] = set()
+ for value in values:
+ text = str(value).strip()
+ key = text.casefold()
+ if not text or key in seen:
+ continue
+ seen.add(key)
+ result.append(text)
+ if len(result) >= limit:
+ break
+ return result
+
+
+def merge_summary_items(items: list[dict[str, Any]], *, fallback_reason: str | None = None) -> dict[str, Any]:
+ merged = {
+ "start": min(float(item.get("start") or 0) for item in items),
+ "end": max(float(item.get("end") or 0) for item in items),
+ "chapter_title": "Итоговый конспект",
+ "overview": " ".join(str(item.get("overview") or "") for item in items).strip(),
+ "key_points": unique_strings([point for item in items for point in item.get("key_points", [])]),
+ "decisions": unique_strings([point for item in items for point in item.get("decisions", [])]),
+ "action_items": unique_strings([point for item in items for point in item.get("action_items", [])]),
+ "risks": unique_strings([point for item in items for point in item.get("risks", [])]),
+ }
+ if fallback_reason:
+ merged["_fallback"] = True
+ merged["_fallback_reason"] = fallback_reason
+ return merged
+
+
+def reduce_summaries(items: list[dict[str, Any]], *, client: OllamaClient) -> dict[str, Any]:
+ if len(items) == 1:
+ return items[0]
+ current = items
+ while len(current) > 1:
+ groups: list[list[dict[str, Any]]] = []
+ group: list[dict[str, Any]] = []
+ size = 0
+ for item in current:
+ serialized = json.dumps(item, ensure_ascii=False)
+ if group and size + len(serialized) > 8000:
+ groups.append(group)
+ group = []
+ size = 0
+ group.append(item)
+ size += len(serialized)
+ if group:
+ groups.append(group)
+ next_items: list[dict[str, Any]] = []
+ for batch in groups:
+ start = min(float(item.get("start") or 0) for item in batch)
+ end = max(float(item.get("end") or start) for item in batch)
+ prompt = (
+ "Объедини промежуточные конспекты одного видео в единый фактический конспект. "
+ "Верни только JSON по схеме, устрани повторы, не добавляй знания извне.\n\n"
+ + json.dumps(batch, ensure_ascii=False)
+ )
+ try:
+ reduced = client.chat(prompt, schema=SUMMARY_SCHEMA)
+ except (RuntimeError, ValueError) as error:
+ return merge_summary_items(current, fallback_reason=str(error))
+ next_items.append({"start": start, "end": end, **reduced})
+ if len(next_items) >= len(current) and len(current) > 1:
+ return merge_summary_items(current)
+ current = next_items
+ return current[0]
+
+
+def render_summary_markdown(summary: dict[str, Any]) -> str:
+ title = str(summary.get("title") or summary.get("asset_id") or "Видео")
+ lines = [f"# {title}", "", "## Кратко", "", str(summary.get("overview") or "Нет данных."), ""]
+
+ def section(name: str, values: list[Any]) -> None:
+ cleaned = unique_strings(values)
+ if not cleaned:
+ return
+ lines.extend([f"## {name}", ""])
+ lines.extend(f"- {value}" for value in cleaned)
+ lines.append("")
+
+ section("Ключевые тезисы", list(summary.get("key_points") or []))
+ chapters = summary.get("chapters") or []
+ if chapters:
+ lines.extend(["## Содержание по времени", ""])
+ for chapter in chapters:
+ start = human_timestamp(float(chapter.get("start") or 0))
+ end = human_timestamp(float(chapter.get("end") or 0))
+ heading = str(chapter.get("title") or "Фрагмент")
+ overview = str(chapter.get("overview") or "").strip()
+ lines.extend([f"### {start}–{end} — {heading}", "", overview, ""])
+ visual_findings = summary.get("visual_findings") or []
+ if visual_findings:
+ lines.extend(["## Визуальные наблюдения", ""])
+ for finding in visual_findings:
+ timestamp = human_timestamp(float(finding.get("timestamp") or 0))
+ description = str(finding.get("description") or "").strip()
+ ocr = str(finding.get("ocr") or "").strip()
+ line = f"- **{timestamp}:** {description}"
+ if ocr:
+ line += f" Текст на экране: «{ocr}»"
+ lines.append(line)
+ lines.append("")
+ section("Решения", list(summary.get("decisions") or []))
+ section("Дальнейшие действия", list(summary.get("action_items") or []))
+ section("Предупреждения", list(summary.get("warnings") or []))
+ section("Риски и оговорки", list(summary.get("risks") or []))
+ lines.extend(
+ [
+ "---",
+ "",
+ f"_Сформировано локально моделью `{summary.get('model')}`; источник текста — локальная ASR._",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def generate_summary(
+ transcript_path: Path,
+ *,
+ visual_timeline_path: Path | None,
+ output_json: Path,
+ output_markdown: Path,
+ client: OllamaClient,
+ force: bool,
+) -> dict[str, Any]:
+ if output_json.exists() and output_markdown.exists() and not force:
+ return read_json(output_json)
+ transcript = read_json(transcript_path)
+ segments = transcript.get("segments")
+ if not isinstance(segments, list):
+ raise ValueError("В transcript.json отсутствует список segments")
+ chunks = transcript_chunks(segments)
+ if not chunks:
+ raise ValueError("Невозможно создать саммари: транскрипт пуст")
+ partials = [summarize_chunk(chunk, client=client) for chunk in chunks]
+ reduced = reduce_summaries(partials, client=client)
+ fallback_reasons = unique_strings(
+ [
+ str(item.get("_fallback_reason") or "")
+ for item in [*partials, reduced]
+ if item.get("_fallback")
+ ],
+ limit=10,
+ )
+ visual_findings: list[dict[str, Any]] = []
+ if visual_timeline_path and visual_timeline_path.exists():
+ timeline = read_json(visual_timeline_path)
+ for frame in timeline.get("frames", []):
+ if not isinstance(frame, dict) or frame.get("status") != "complete":
+ continue
+ if not frame.get("description") and not frame.get("ocr"):
+ continue
+ visual_findings.append(
+ {
+ "timestamp": float(frame.get("timestamp") or 0),
+ "description": str(frame.get("description") or "").strip(),
+ "ocr": str(frame.get("ocr") or "").strip() or None,
+ "actions": frame.get("actions") or [],
+ }
+ )
+ source_title = transcript.get("title")
+ topic_title = generate_topic_title(
+ reduced,
+ partials,
+ source_title=source_title,
+ client=client,
+ )
+ summary = {
+ "asset_id": transcript.get("asset_id"),
+ "title": topic_title,
+ "source_title": source_title,
+ "status": "complete",
+ "model": client.model,
+ "generation_mode": "hybrid_fallback" if fallback_reasons else "model",
+ "source": "local_transcript_and_visual_timeline",
+ "generated_at": utc_now(),
+ "overview": str(reduced.get("overview") or "").strip(),
+ "key_points": unique_strings(list(reduced.get("key_points") or [])),
+ "chapters": [
+ {
+ "start": float(item.get("start") or 0),
+ "end": float(item.get("end") or 0),
+ "title": str(item.get("chapter_title") or "Фрагмент").strip(),
+ "overview": str(item.get("overview") or "").strip(),
+ }
+ for item in partials
+ ],
+ "visual_findings": visual_findings,
+ "decisions": unique_strings(list(reduced.get("decisions") or [])),
+ "action_items": unique_strings(list(reduced.get("action_items") or [])),
+ "warnings": [
+ "Часть саммари сформирована детерминированным fallback из локального транскрипта: " + reason
+ for reason in fallback_reasons
+ ],
+ "risks": unique_strings(list(reduced.get("risks") or [])),
+ }
+ atomic_write_json(output_json, summary)
+ atomic_write_text(output_markdown, render_summary_markdown(summary))
+ return summary
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Local OCR/vision and summaries through Ollama")
+ parser.add_argument("--ollama-url", default=DEFAULT_OLLAMA_URL)
+ parser.add_argument("--model", default=DEFAULT_MODEL)
+ parser.add_argument("--timeout", type=int, default=900)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ visuals = subparsers.add_parser("visuals")
+ visuals.add_argument("--timeline", type=Path, required=True)
+ visuals.add_argument("--force", action="store_true")
+
+ summary = subparsers.add_parser("summary")
+ summary.add_argument("--transcript", type=Path, required=True)
+ summary.add_argument("--visual-timeline", type=Path)
+ summary.add_argument("--output-json", type=Path, required=True)
+ summary.add_argument("--output-markdown", type=Path, required=True)
+ summary.add_argument("--force", action="store_true")
+
+ args = parser.parse_args()
+ client = OllamaClient(url=args.ollama_url, model=args.model, timeout=args.timeout)
+ if args.command == "visuals":
+ result = analyze_visual_timeline(args.timeline, client=client, force=args.force)
+ else:
+ result = generate_summary(
+ args.transcript,
+ visual_timeline_path=args.visual_timeline,
+ output_json=args.output_json,
+ output_markdown=args.output_markdown,
+ client=client,
+ force=args.force,
+ )
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/video/telegram_publication.py b/server/linux/video/telegram_publication.py
new file mode 100644
index 0000000..e6afd3d
--- /dev/null
+++ b/server/linux/video/telegram_publication.py
@@ -0,0 +1,481 @@
+#!/usr/bin/env python3
+"""Build a safe, resumable Telegram publication outbox for one video.
+
+This module never contacts Telegram. It renders exact action descriptors for
+Telegram Suite. A fresh video from Ivan is the direct command for the exact
+low-risk publication package, so descriptors request owner-direct execution
+without a duplicate confirmation prompt.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+
+
+SCHEMA_VERSION = 1
+SAFE_TEXT_LIMIT = 3900
+DEFAULT_CONNECTION = "bot:voiceswitch"
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def read_json(path: Path, *, required: bool = True) -> dict[str, Any]:
+ if not path.exists():
+ if required:
+ raise FileNotFoundError(f"Не найден обязательный артефакт: {path}")
+ return {}
+ value = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(value, dict):
+ raise ValueError(f"Ожидался JSON-объект: {path}")
+ return value
+
+
+def atomic_write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def normalize_space(value: Any) -> str:
+ return re.sub(r"\s+", " ", str(value or "")).strip()
+
+
+def stable_key(*parts: Any) -> str:
+ source = "\x1f".join(str(part) for part in parts)
+ digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:20]
+ return f"video-{digest}"
+
+
+def file_digest(path: str | Path) -> str:
+ digest = hashlib.sha256()
+ with Path(path).open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def parse_chat_id(value: str | int) -> str | int:
+ rendered = str(value).strip()
+ if re.fullmatch(r"-?\d+", rendered):
+ return int(rendered)
+ if not rendered:
+ raise ValueError("Telegram chat id не может быть пустым")
+ return rendered
+
+
+def make_topic_name(title: str) -> str:
+ cleaned = normalize_space(title).strip(" 🎬:—–-.,;!?«»\"'").replace("/", "/")
+ words = re.findall(r"[0-9A-Za-zА-Яа-яЁё]+(?:[-–][0-9A-Za-zА-Яа-яЁё]+)*", cleaned)
+ generic = {"видео", "тестовое видео", "фрагмент транскрипта", "итоговый конспект"}
+ if re.search(r"\.(?:mp4|mov|mkv|webm|avi|mp3|wav|m4a)$", cleaned, flags=re.IGNORECASE):
+ raise ValueError("Название топика не должно быть именем файла")
+ if re.fullmatch(r"[0-9a-f]{12,64}", cleaned, flags=re.IGNORECASE) or re.fullmatch(
+ r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", cleaned, flags=re.IGNORECASE
+ ):
+ raise ValueError("Название топика не должно быть техническим идентификатором")
+ if cleaned.casefold() in generic or len(words) < 3:
+ raise ValueError("Название топика должно содержательно описывать видео и включать 3–8 слов")
+ words = words[:8]
+ cleaned = " ".join(words)
+ prefix = "🎬 "
+ available = 60 - len(prefix)
+ while len(words) >= 3 and len(cleaned) > available:
+ words.pop()
+ cleaned = " ".join(words)
+ if len(words) < 3:
+ raise ValueError("Название топика нельзя сократить до 60 символов без потери смысла")
+ return prefix + cleaned
+
+
+def human_duration(value: Any) -> str:
+ try:
+ total = max(0, int(round(float(value))))
+ except (TypeError, ValueError):
+ return "не определена"
+ hours, remainder = divmod(total, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ return f"{hours}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes}:{seconds:02d}"
+
+
+def timestamp(value: Any) -> str:
+ try:
+ total = max(0, int(float(value)))
+ except (TypeError, ValueError):
+ total = 0
+ hours, remainder = divmod(total, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ return f"{hours}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes}:{seconds:02d}"
+
+
+def timestamp_url(url: str | None, seconds: Any) -> str | None:
+ if not url:
+ return None
+ parsed = urlparse(url)
+ allowed = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"}
+ if parsed.hostname not in allowed:
+ return None
+ query = dict(parse_qsl(parsed.query, keep_blank_values=True))
+ query["t"] = str(max(0, int(float(seconds or 0))))
+ return urlunparse(parsed._replace(query=urlencode(query)))
+
+
+def source_label(source: dict[str, Any]) -> str:
+ if source.get("canonical_url"):
+ return str(source["canonical_url"])
+ digest = normalize_space(source.get("sha256"))[:12]
+ label = "Telegram-вложение" if source.get("kind") == "telegram" else "Локальный файл"
+ return f"{label} · SHA-256 {digest}…" if digest else label
+
+
+def author_label(metadata: dict[str, Any]) -> str | None:
+ for key in ("uploader", "channel", "creator", "artist", "author"):
+ value = normalize_space(metadata.get(key))
+ if value:
+ return value
+ return None
+
+
+def engine_label(transcript: dict[str, Any], manifest: dict[str, Any]) -> str:
+ asr = transcript.get("asr") if isinstance(transcript.get("asr"), dict) else {}
+ engines = asr.get("engines_used")
+ if isinstance(engines, list) and engines:
+ return ", ".join(normalize_space(item) for item in engines if normalize_space(item))
+ return normalize_space(asr.get("requested_engine") or manifest.get("engine") or "не определён")
+
+
+def stage_warnings(job: dict[str, Any]) -> list[str]:
+ result: list[str] = []
+ stages = job.get("stages") if isinstance(job.get("stages"), dict) else {}
+ labels = {
+ "alignment": "точное выравнивание",
+ "diarization": "диаризация",
+ "visuals": "OCR/vision",
+ "summary": "саммари",
+ }
+ for stage, label in labels.items():
+ value = stages.get(stage) if isinstance(stages.get(stage), dict) else {}
+ status = normalize_space(value.get("status"))
+ if status in {"skipped", "partial", "failed"}:
+ reason = normalize_space(value.get("reason") or value.get("error"))
+ internal_markers = ("Traceback", "eback (most recent call", 'File "', "/home/", "RuntimeError:")
+ if any(marker in reason for marker in internal_markers):
+ reason = ""
+ elif len(reason) > 180:
+ reason = reason[:179].rstrip() + "…"
+ result.append(f"{label}: {status}" + (f" ({reason})" if reason else ""))
+ return result
+
+
+def bounded_text(value: str, *, limit: int = SAFE_TEXT_LIMIT) -> str:
+ if len(value) <= limit:
+ return value
+ marker = "\n\n…Текст сокращён; полный материал приложен файлами."
+ return value[: limit - len(marker)].rstrip() + marker
+
+
+def build_summary_text(
+ *,
+ transcript: dict[str, Any],
+ summary: dict[str, Any],
+ manifest: dict[str, Any],
+ metadata: dict[str, Any],
+ source: dict[str, Any],
+ qc: dict[str, Any],
+ job: dict[str, Any],
+) -> str:
+ title = normalize_space(summary.get("title") or transcript.get("title") or manifest.get("title") or "Видео")
+ profile = normalize_space(manifest.get("profile") or transcript.get("profile") or job.get("profile") or "standard")
+ duration = manifest.get("duration", transcript.get("duration"))
+ lines = [
+ f"🎬 {title}",
+ "",
+ f"Источник: {source_label(source)}",
+ f"Длительность: {human_duration(duration)}",
+ ]
+ author = author_label(metadata)
+ if author:
+ lines.append(f"Автор/канал: {author}")
+ lines.extend(
+ [
+ f"Профиль: {profile}",
+ f"Локальная ASR: {engine_label(transcript, manifest)}",
+ f"QC: {normalize_space(qc.get('status') or manifest.get('qc_status') or 'не определён')}",
+ ]
+ )
+
+ overview = normalize_space(summary.get("overview"))
+ if overview:
+ lines.extend(["", "Кратко", overview])
+ key_points = [normalize_space(item) for item in summary.get("key_points", []) if normalize_space(item)]
+ if key_points:
+ lines.extend(["", "Ключевые тезисы"])
+ lines.extend(f"• {item}" for item in key_points[:6])
+ chapters = [item for item in summary.get("chapters", []) if isinstance(item, dict)]
+ if chapters:
+ lines.extend(["", "Ключевые фрагменты"])
+ base_url = source.get("canonical_url")
+ for chapter in chapters[:6]:
+ start = chapter.get("start", 0)
+ chapter_title = normalize_space(chapter.get("title") or chapter.get("chapter_title") or "Фрагмент")
+ link = timestamp_url(str(base_url), start) if base_url else None
+ lines.append(f"• {timestamp(start)} — {chapter_title}" + (f" — {link}" if link else ""))
+ risks = [normalize_space(item) for item in summary.get("risks", []) if normalize_space(item)]
+ if risks:
+ lines.extend(["", "Риски и оговорки"])
+ lines.extend(f"• {item}" for item in risks[:4])
+ warnings = stage_warnings(job)
+ if warnings:
+ lines.extend(["", "Ограничения обработки"])
+ lines.extend(f"• {item}" for item in warnings)
+ lines.extend(["", "Полные саммари и транскрипт приложены файлами."])
+ return bounded_text("\n".join(lines))
+
+
+def build_status_text(manifest: dict[str, Any], qc: dict[str, Any]) -> str:
+ return "\n".join(
+ [
+ "📦 Локальная обработка готова",
+ f"Профиль: {normalize_space(manifest.get('profile') or 'standard')}",
+ f"QC: {normalize_space(qc.get('status') or manifest.get('qc_status') or 'не определён')}",
+ f"OCR/vision: {normalize_space(manifest.get('vision_status') or 'не применялся')}",
+ f"Саммари: {normalize_space(manifest.get('summary_status') or 'не создано')}",
+ ]
+ )
+
+
+def build_publication_plan(
+ artifact_dir: Path,
+ *,
+ chat_id: str | int,
+ source_topic_id: int | None = None,
+ connection: str = DEFAULT_CONNECTION,
+ output: Path | None = None,
+) -> dict[str, Any]:
+ artifact_dir = artifact_dir.expanduser().resolve(strict=True)
+ transcript = read_json(artifact_dir / "transcript.json")
+ manifest = read_json(artifact_dir / "manifest.json")
+ summary = read_json(artifact_dir / "summary.json", required=False)
+ metadata = read_json(artifact_dir / "metadata.json", required=False)
+ source = read_json(artifact_dir / "source-info.json")
+ qc = read_json(artifact_dir / "qc.json")
+ job = read_json(artifact_dir / "job-state.json")
+ asset_id = normalize_space(manifest.get("asset_id") or transcript.get("asset_id"))
+ if not asset_id:
+ raise ValueError("В артефактах отсутствует asset_id")
+ title = normalize_space(summary.get("title"))
+ if not title:
+ raise ValueError("В summary.json отсутствует смысловое название для Telegram-топика")
+ target_chat = parse_chat_id(chat_id)
+ output = output or artifact_dir / "telegram-publication.json"
+
+ previous: dict[str, Any] = {}
+ if output.exists():
+ previous = read_json(output)
+ old_target = previous.get("target") if isinstance(previous.get("target"), dict) else {}
+ if previous.get("asset_id") != asset_id or old_target.get("chat_id") != target_chat:
+ raise ValueError("Существующий outbox относится к другому asset или Telegram-чату")
+
+ attachments: list[dict[str, Any]] = []
+ for kind, filename, caption in (
+ ("summary_document", "summary.md", "Содержательное саммари"),
+ ("transcript_document", "transcript.md", "Полный локальный транскрипт"),
+ ):
+ path = artifact_dir / filename
+ if not path.is_file():
+ raise FileNotFoundError(f"Не найден обязательный файл публикации: {path}")
+ attachments.append({"kind": kind, "path": str(path), "caption": caption})
+
+ now = utc_now()
+ delivery = previous.get("delivery") or {
+ "status": "pending_topic",
+ "topic_id": None,
+ "messages": {},
+ "completed_at": None,
+ }
+ if delivery.get("status") == "complete":
+ delivery["status"] = "completed"
+ plan = {
+ "schema_version": SCHEMA_VERSION,
+ "asset_id": asset_id,
+ "artifact_dir": str(artifact_dir),
+ "created_at": previous.get("created_at") or now,
+ "updated_at": now,
+ "target": {"connection": connection, "chat_id": target_chat, "source_topic_id": source_topic_id},
+ "topic": {
+ "name": make_topic_name(title),
+ "idempotency_key": stable_key(asset_id, target_chat, "topic", SCHEMA_VERSION),
+ },
+ "content": {
+ "status_ready": build_status_text(manifest, qc),
+ "summary": build_summary_text(
+ transcript=transcript,
+ summary=summary,
+ manifest=manifest,
+ metadata=metadata,
+ source=source,
+ qc=qc,
+ job=job,
+ ),
+ },
+ "attachments": attachments,
+ "delivery": delivery,
+ }
+ atomic_write_json(output, plan)
+ return plan
+
+
+def action_descriptors(plan: dict[str, Any], *, topic_id: int | None = None) -> list[dict[str, Any]]:
+ target = plan["target"]
+ delivery = plan.get("delivery") if isinstance(plan.get("delivery"), dict) else {}
+ messages = delivery.get("messages") if isinstance(delivery.get("messages"), dict) else {}
+ resolved_topic = topic_id or delivery.get("topic_id")
+ connection = target["connection"]
+ chat_id = target["chat_id"]
+ asset_id = plan["asset_id"]
+
+ if not resolved_topic:
+ return [
+ {
+ "kind": "topic",
+ "connection": connection,
+ "operation": "create_forum_topic",
+ "params": {"chat_id": chat_id, "name": plan["topic"]["name"]},
+ "idempotency_key": plan["topic"]["idempotency_key"],
+ "owner_direct_approval": True,
+ }
+ ]
+
+ resolved_topic = int(resolved_topic)
+ common = {"chat_id": chat_id, "message_thread_id": resolved_topic}
+ result: list[dict[str, Any]] = []
+ for kind, text_key in (("status", "status_ready"), ("summary", "summary")):
+ if kind not in messages:
+ text = plan["content"][text_key]
+ result.append(
+ {
+ "kind": kind,
+ "connection": connection,
+ "operation": "send_message",
+ "params": {**common, "text": text},
+ "idempotency_key": stable_key(
+ asset_id, chat_id, resolved_topic, kind, text, SCHEMA_VERSION
+ ),
+ "owner_direct_approval": True,
+ }
+ )
+ for attachment in plan.get("attachments", []):
+ kind = attachment["kind"]
+ if kind in messages:
+ continue
+ result.append(
+ {
+ "kind": kind,
+ "connection": connection,
+ "operation": "send_media",
+ "params": {
+ **common,
+ "media_type": "document",
+ "file": {"$file": attachment["path"]},
+ "caption": attachment["caption"],
+ },
+ "idempotency_key": stable_key(
+ asset_id,
+ chat_id,
+ resolved_topic,
+ kind,
+ file_digest(attachment["path"]),
+ SCHEMA_VERSION,
+ ),
+ "owner_direct_approval": True,
+ }
+ )
+ return result
+
+
+def record_topic(plan_path: Path, topic_id: int) -> dict[str, Any]:
+ if topic_id < 1:
+ raise ValueError("topic_id должен быть положительным")
+ plan = read_json(plan_path)
+ delivery = plan.setdefault("delivery", {})
+ existing = delivery.get("topic_id")
+ if existing and int(existing) != topic_id:
+ raise ValueError(f"Outbox уже привязан к другому topic_id: {existing}")
+ delivery["topic_id"] = topic_id
+ delivery["status"] = "pending_messages"
+ plan["updated_at"] = utc_now()
+ atomic_write_json(plan_path, plan)
+ return plan
+
+
+def record_message(plan_path: Path, kind: str, message_id: int) -> dict[str, Any]:
+ if message_id < 1:
+ raise ValueError("message_id должен быть положительным")
+ plan = read_json(plan_path)
+ delivery = plan.setdefault("delivery", {})
+ messages = delivery.setdefault("messages", {})
+ existing = messages.get(kind)
+ if existing and int(existing) != message_id:
+ raise ValueError(f"Для {kind} уже записан другой message_id: {existing}")
+ messages[kind] = message_id
+ expected = {"status", "summary", *(item["kind"] for item in plan.get("attachments", []))}
+ if expected.issubset(messages):
+ delivery["status"] = "completed"
+ delivery["completed_at"] = delivery.get("completed_at") or utc_now()
+ else:
+ delivery["status"] = "pending_messages"
+ plan["updated_at"] = utc_now()
+ atomic_write_json(plan_path, plan)
+ return plan
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Telegram topic publication outbox for video artifacts")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ build = subparsers.add_parser("build")
+ build.add_argument("artifact_dir", type=Path)
+ build.add_argument("--chat-id", required=True)
+ build.add_argument("--source-topic-id", type=int)
+ build.add_argument("--connection", default=DEFAULT_CONNECTION)
+ build.add_argument("--output", type=Path)
+ actions = subparsers.add_parser("actions")
+ actions.add_argument("plan", type=Path)
+ actions.add_argument("--topic-id", type=int)
+ bind = subparsers.add_parser("record-topic")
+ bind.add_argument("plan", type=Path)
+ bind.add_argument("topic_id", type=int)
+ record = subparsers.add_parser("record-message")
+ record.add_argument("plan", type=Path)
+ record.add_argument("kind")
+ record.add_argument("message_id", type=int)
+
+ args = parser.parse_args()
+ if args.command == "build":
+ result = build_publication_plan(
+ args.artifact_dir,
+ chat_id=args.chat_id,
+ source_topic_id=args.source_topic_id,
+ connection=args.connection,
+ output=args.output,
+ )
+ elif args.command == "actions":
+ result = action_descriptors(read_json(args.plan), topic_id=args.topic_id)
+ elif args.command == "record-topic":
+ result = record_topic(args.plan, args.topic_id)
+ else:
+ result = record_message(args.plan, args.kind, args.message_id)
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/video/transcribe_mitim_stt.py b/server/linux/video/transcribe_mitim_stt.py
new file mode 100644
index 0000000..eb2efc3
--- /dev/null
+++ b/server/linux/video/transcribe_mitim_stt.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+"""Transcribe long WAV files through the local loopback-only mitim-stt service.
+
+The resident service accepts files up to 30 minutes and returns one text block.
+This adapter cuts long recordings into timestamped pieces, adds a small overlap,
+deduplicates exact words at joins, and checkpoints every completed request.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+import wave
+from pathlib import Path
+from typing import Any
+
+
+WORD_RE = re.compile(r"[\wёЁ-]+", re.UNICODE)
+
+
+def resolve_binary(name: str, configured: Path | None) -> str:
+ resolved = str(configured.expanduser().resolve()) if configured else shutil.which(name)
+ if not resolved:
+ raise RuntimeError(f"{name} не найден")
+ path = Path(resolved)
+ if not path.is_file() or not os.access(path, os.X_OK):
+ raise RuntimeError(f"{name} недоступен для запуска: {path}")
+ return resolved
+
+
+def media_duration(path: Path, ffprobe: str) -> float:
+ if ffprobe:
+ completed = subprocess.run(
+ [
+ ffprobe,
+ "-v",
+ "error",
+ "-show_entries",
+ "format=duration",
+ "-of",
+ "default=noprint_wrappers=1:nokey=1",
+ str(path),
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ if completed.returncode == 0:
+ try:
+ duration = float(completed.stdout.strip())
+ if duration > 0:
+ return duration
+ except ValueError:
+ pass
+ with wave.open(str(path), "rb") as audio:
+ return audio.getnframes() / float(audio.getframerate())
+
+
+def post_transcribe(endpoint: str, audio: Path, engine: str) -> dict[str, Any]:
+ body = json.dumps({"path": str(audio.resolve()), "engine": engine}).encode("utf-8")
+ request = urllib.request.Request(
+ endpoint,
+ data=body,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ result: Any = None
+ for attempt in range(4):
+ try:
+ with urllib.request.urlopen(request, timeout=3600) as response:
+ result = json.load(response)
+ break
+ except urllib.error.HTTPError as error:
+ detail = error.read().decode("utf-8", errors="replace")
+ if error.code not in {429, 500, 502, 503, 504} or attempt == 3:
+ raise RuntimeError(f"mitim-stt HTTP {error.code}: {detail}") from error
+ except urllib.error.URLError as error:
+ if attempt == 3:
+ raise RuntimeError(f"mitim-stt недоступен: {error}") from error
+ time.sleep(2**attempt)
+ if not isinstance(result, dict) or "text" not in result:
+ raise RuntimeError(f"Некорректный ответ mitim-stt: {result!r}")
+ return result
+
+
+def normalized_words(text: str) -> list[str]:
+ return [word.casefold() for word in WORD_RE.findall(text)]
+
+
+def remove_overlap(previous: str, current: str, maximum_words: int = 24) -> str:
+ """Remove an exact 3+ word suffix/prefix repeated by overlapped audio."""
+ previous_words = normalized_words(previous)
+ current_matches = list(WORD_RE.finditer(current))
+ current_words = [match.group(0).casefold() for match in current_matches]
+ limit = min(maximum_words, len(previous_words), len(current_words))
+ for count in range(limit, 2, -1):
+ if previous_words[-count:] == current_words[:count]:
+ return current[current_matches[count - 1].end() :].lstrip(" ,.;:—–-\t")
+ return current.strip()
+
+
+def quality_flags(text: str, duration: float, language: Any, engine: Any) -> list[str]:
+ flags: list[str] = []
+ words = normalized_words(text)
+ if not words:
+ return ["empty_text"]
+ if duration >= 20 and len(words) / duration < 0.03:
+ flags.append("low_text_density")
+ if len(words) >= 12:
+ most_common = max(words.count(word) for word in set(words))
+ if most_common / len(words) >= 0.45:
+ flags.append("high_token_repetition")
+ letters = [character for character in text if character.isalpha()]
+ cyrillic = [character for character in letters if "а" <= character.casefold() <= "я" or character.casefold() == "ё"]
+ if str(language) == "ru" and str(engine).startswith("gigaam") and len(letters) >= 20:
+ if len(cyrillic) / len(letters) < 0.2:
+ flags.append("unexpected_script_for_russian")
+ return flags
+
+
+def extract_chunk(ffmpeg: str, source: Path, output: Path, start: float, duration: float) -> None:
+ command = [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-ss",
+ f"{start:.3f}",
+ "-t",
+ f"{duration:.3f}",
+ "-i",
+ str(source),
+ "-vn",
+ "-ac",
+ "1",
+ "-ar",
+ "16000",
+ "-c:a",
+ "pcm_s16le",
+ str(output),
+ ]
+ subprocess.run(command, check=True, timeout=180)
+
+
+def atomic_write_json(path: Path, value: Any) -> None:
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
+ temporary.replace(path)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Long-form adapter for local mitim-stt")
+ parser.add_argument("--audio", required=True, type=Path)
+ parser.add_argument("--endpoint", default="http://127.0.0.1:18790/transcribe")
+ parser.add_argument("--engine", choices=("auto", "gigaam", "whisper"), default="auto")
+ parser.add_argument("--chunk-seconds", type=float, default=60.0)
+ parser.add_argument("--overlap-seconds", type=float, default=1.5)
+ parser.add_argument("--checkpoint", type=Path)
+ parser.add_argument("--output", type=Path)
+ parser.add_argument("--ffmpeg-path", type=Path)
+ parser.add_argument("--ffprobe-path", type=Path)
+ args = parser.parse_args()
+
+ source = args.audio.resolve(strict=True)
+ ffmpeg = resolve_binary("ffmpeg", args.ffmpeg_path)
+ ffprobe = resolve_binary("ffprobe", args.ffprobe_path)
+ if args.chunk_seconds < 30 or args.chunk_seconds > 600:
+ raise ValueError("chunk-seconds должен быть от 30 до 600")
+ if args.overlap_seconds < 0 or args.overlap_seconds >= args.chunk_seconds / 4:
+ raise ValueError("Некорректный overlap-seconds")
+
+ total_duration = media_duration(source, ffprobe)
+ checkpoint = args.checkpoint or source.with_suffix(".mitim-stt-checkpoint.json")
+ fingerprint = {
+ "path": str(source),
+ "size": source.stat().st_size,
+ "mtime_ns": source.stat().st_mtime_ns,
+ "duration": round(total_duration, 3),
+ "chunk_seconds": args.chunk_seconds,
+ "overlap_seconds": args.overlap_seconds,
+ "endpoint": args.endpoint,
+ "requested_engine": args.engine,
+ "ffmpeg_path": ffmpeg,
+ "ffprobe_path": ffprobe,
+ }
+
+ state: dict[str, Any] = {"fingerprint": fingerprint, "segments": []}
+ if checkpoint.exists():
+ candidate = json.loads(checkpoint.read_text(encoding="utf-8"))
+ if candidate.get("fingerprint") == fingerprint and isinstance(candidate.get("segments"), list):
+ state = candidate
+
+ segment_count = max(1, math.ceil(total_duration / args.chunk_seconds))
+ done = {int(segment["index"]): segment for segment in state["segments"]}
+ started_all = time.perf_counter()
+
+ for index in range(segment_count):
+ if index in done:
+ print(f"[{index + 1}/{segment_count}] checkpoint", file=sys.stderr, flush=True)
+ continue
+
+ nominal_start = index * args.chunk_seconds
+ nominal_end = min(total_duration, (index + 1) * args.chunk_seconds)
+ actual_start = max(0.0, nominal_start - (args.overlap_seconds if index else 0.0))
+ actual_duration = nominal_end - actual_start
+ print(
+ f"[{index + 1}/{segment_count}] {nominal_start:.1f}-{nominal_end:.1f}s -> mitim-stt",
+ file=sys.stderr,
+ flush=True,
+ )
+ with tempfile.TemporaryDirectory(prefix="youtube-mitim-stt-") as temporary:
+ chunk = Path(temporary) / f"chunk-{index:04d}.wav"
+ extract_chunk(ffmpeg, source, chunk, actual_start, actual_duration)
+ result = post_transcribe(args.endpoint, chunk, args.engine)
+
+ previous_text = ""
+ if index > 0 and index - 1 in done:
+ previous_text = str(done[index - 1].get("text", ""))
+ text = remove_overlap(previous_text, str(result.get("text", "")).strip())
+ segment = {
+ "index": index,
+ "start": round(nominal_start, 3),
+ "end": round(nominal_end, 3),
+ "text": text,
+ "confidence": None,
+ "source": f"mitim-stt:{result.get('engine', args.engine)}",
+ "engine": result.get("engine", args.engine),
+ "language": result.get("language"),
+ "latency": result.get("latency"),
+ "quality_flags": quality_flags(
+ text,
+ nominal_end - nominal_start,
+ result.get("language"),
+ result.get("engine", args.engine),
+ ),
+ }
+ done[index] = segment
+ state["segments"] = [done[key] for key in sorted(done)]
+ atomic_write_json(checkpoint, state)
+
+ segments = [done[key] for key in sorted(done)]
+ for segment in segments:
+ segment.setdefault(
+ "quality_flags",
+ quality_flags(
+ str(segment.get("text", "")),
+ float(segment.get("end", 0)) - float(segment.get("start", 0)),
+ segment.get("language"),
+ segment.get("engine"),
+ ),
+ )
+ payload = {
+ "segments": segments,
+ "metadata": {
+ "adapter": "mitim-stt-long-form-v2",
+ "requested_engine": args.engine,
+ "engines_used": sorted({str(segment.get("engine")) for segment in segments}),
+ "duration": round(total_duration, 3),
+ "segment_count": len(segments),
+ "empty_segment_count": sum(not str(segment.get("text", "")).strip() for segment in segments),
+ "flagged_segment_count": sum(bool(segment.get("quality_flags")) for segment in segments),
+ "coverage": {
+ "start": 0.0,
+ "end": round(max((float(segment.get("end", 0)) for segment in segments), default=0.0), 3),
+ "expected_end": round(total_duration, 3),
+ },
+ "elapsed": round(time.perf_counter() - started_all, 3),
+ "checkpoint": str(checkpoint),
+ },
+ }
+ rendered = json.dumps(payload, ensure_ascii=False, indent=2)
+ if args.output:
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ else:
+ print(rendered)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/server/linux/video/video_pipeline.py b/server/linux/video/video_pipeline.py
new file mode 100644
index 0000000..8c46ab0
--- /dev/null
+++ b/server/linux/video/video_pipeline.py
@@ -0,0 +1,993 @@
+#!/usr/bin/env python3
+"""Local-first video transcription pipeline built around resident mitim-stt.
+
+Supported sources:
+- YouTube URLs through yt-dlp
+- local audio/video files, including media downloaded from Telegram
+
+GigaAM is the canonical Russian ASR. YouTube captions are preserved only as
+auxiliary evidence. Optional external commands can add forced alignment and
+speaker diarization without changing the stable voice-message endpoint.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlparse
+
+from telegram_publication import DEFAULT_CONNECTION as DEFAULT_TELEGRAM_CONNECTION
+from telegram_publication import build_publication_plan
+
+
+PIPELINE_VERSION = "mitim-video-pipeline-v3"
+PROJECT_DIR = Path(__file__).resolve().parent
+DEFAULT_OUTPUT_ROOT = PROJECT_DIR / "knowledge" / "videos"
+DEFAULT_STT_ADAPTER = PROJECT_DIR / "transcribe_mitim_stt.py"
+DEFAULT_OLLAMA_ENRICHER = PROJECT_DIR / "enrich_ollama.py"
+YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"}
+PROFILES = {
+ "quick": {"alignment": False, "diarization": False, "visuals": False, "frame_interval": 0},
+ "standard": {"alignment": True, "diarization": False, "visuals": True, "frame_interval": 300},
+ "interview": {"alignment": True, "diarization": True, "visuals": True, "frame_interval": 300},
+ "multilingual": {"alignment": True, "diarization": False, "visuals": True, "frame_interval": 300},
+ "deep": {"alignment": True, "diarization": True, "visuals": True, "frame_interval": 60},
+}
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def atomic_write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ temporary.replace(path)
+
+
+def atomic_write_text(path: Path, value: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(value, encoding="utf-8")
+ temporary.replace(path)
+
+
+def run_cmd(command: list[str], *, timeout: int = 3600) -> str:
+ completed = subprocess.run(
+ command,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ if completed.returncode != 0:
+ detail = (completed.stderr or completed.stdout or "command failed").strip()
+ raise RuntimeError(f"Команда завершилась с кодом {completed.returncode}: {detail[-1200:]}")
+ return completed.stdout.strip()
+
+
+def require_binary(name: str, configured: Path | str | None = None) -> str:
+ resolved = str(Path(configured).expanduser().resolve()) if configured else shutil.which(name)
+ if not resolved:
+ raise RuntimeError(f"Не найдена обязательная команда: {name}")
+ path = Path(resolved)
+ if not path.is_file() or not os.access(path, os.X_OK):
+ raise RuntimeError(f"Команда {name} недоступна для запуска: {path}")
+ return resolved
+
+
+def file_sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for block in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def safe_name(value: str, fallback: str) -> str:
+ cleaned = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._-]+", "-", value).strip("-._")
+ return (cleaned[:96] or fallback).strip("-._") or fallback
+
+
+def seconds_to_timestamp(seconds: float, *, srt: bool = False) -> str:
+ milliseconds = max(0, int(round(seconds * 1000)))
+ hours, remainder = divmod(milliseconds, 3_600_000)
+ minutes, remainder = divmod(remainder, 60_000)
+ secs, millis = divmod(remainder, 1000)
+ separator = "," if srt else "."
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{millis:03d}"
+
+
+def human_timestamp(seconds: float) -> str:
+ total = max(0, int(seconds))
+ hours, remainder = divmod(total, 3600)
+ minutes, secs = divmod(remainder, 60)
+ return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
+
+
+def is_youtube_url(value: str) -> bool:
+ parsed = urlparse(value)
+ return parsed.scheme in {"http", "https"} and parsed.hostname in YOUTUBE_HOSTS
+
+
+def probe_media(path: Path, *, ffprobe: str) -> dict[str, Any]:
+ data = json.loads(
+ run_cmd([ffprobe, "-v", "error", "-show_format", "-show_streams", "-of", "json", str(path)], timeout=120)
+ )
+ duration = 0.0
+ try:
+ duration = float(data.get("format", {}).get("duration") or 0)
+ except (TypeError, ValueError):
+ pass
+ if duration <= 0:
+ for stream in data.get("streams", []):
+ try:
+ duration = max(duration, float(stream.get("duration") or 0))
+ except (TypeError, ValueError):
+ continue
+ return {
+ "duration": round(duration, 3),
+ "format": data.get("format", {}),
+ "streams": data.get("streams", []),
+ "has_video": any(stream.get("codec_type") == "video" for stream in data.get("streams", [])),
+ "has_audio": any(stream.get("codec_type") == "audio" for stream in data.get("streams", [])),
+ }
+
+
+def prepare_stage_audio(media: Path, output: Path, *, ffmpeg: str, force: bool) -> Path:
+ output.parent.mkdir(parents=True, exist_ok=True)
+ if output.exists() and not force:
+ return output
+ run_cmd(
+ [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ str(media),
+ "-vn",
+ "-ac",
+ "1",
+ "-ar",
+ "16000",
+ "-c:a",
+ "pcm_s16le",
+ str(output),
+ ],
+ timeout=7200,
+ )
+ return output
+
+
+def choose_youtube_caption(metadata: dict[str, Any], languages: list[str]) -> tuple[str, str] | None:
+ for source, bucket_name in (("youtube_manual", "subtitles"), ("youtube_auto", "automatic_captions")):
+ bucket = metadata.get(bucket_name, {})
+ if not isinstance(bucket, dict):
+ continue
+ for language in languages:
+ if bucket.get(language):
+ return source, language
+ for language, tracks in bucket.items():
+ if tracks:
+ return source, language
+ return None
+
+
+def download_youtube_caption(
+ url: str,
+ destination: Path,
+ source: str,
+ language: str,
+ *,
+ ffmpeg: str,
+) -> Path:
+ destination.mkdir(parents=True, exist_ok=True)
+ command = [
+ require_binary("yt-dlp"),
+ "--no-warnings",
+ "--no-playlist",
+ "--skip-download",
+ "--sub-lang",
+ language,
+ "--sub-format",
+ "vtt",
+ "--convert-subs",
+ "vtt",
+ "--ffmpeg-location",
+ ffmpeg,
+ "--output",
+ str(destination / "captions.%(ext)s"),
+ "--write-subs" if source == "youtube_manual" else "--write-auto-subs",
+ url,
+ ]
+ run_cmd(command)
+ candidates = sorted(destination.glob("*.vtt"))
+ if not candidates:
+ raise RuntimeError("yt-dlp не создал файл субтитров")
+ return candidates[0]
+
+
+def download_youtube_media(url: str, work_dir: Path, include_video: bool, *, ffmpeg: str) -> Path:
+ work_dir.mkdir(parents=True, exist_ok=True)
+ yt_dlp = require_binary("yt-dlp")
+ output = str(work_dir / "source.%(ext)s")
+ if include_video:
+ command = [
+ yt_dlp,
+ "--no-warnings",
+ "--no-playlist",
+ "-f",
+ "bv*[height<=720]+ba/b[height<=720]/b",
+ "--merge-output-format",
+ "mp4",
+ "--ffmpeg-location",
+ ffmpeg,
+ "--output",
+ output,
+ url,
+ ]
+ else:
+ command = [
+ yt_dlp,
+ "--no-warnings",
+ "--no-playlist",
+ "-x",
+ "--audio-format",
+ "wav",
+ "--audio-quality",
+ "0",
+ "--ffmpeg-location",
+ ffmpeg,
+ "--postprocessor-args",
+ "ffmpeg:-ac 1 -ar 16000",
+ "--output",
+ output,
+ url,
+ ]
+ run_cmd(command, timeout=7200)
+ candidates = sorted(
+ path for path in work_dir.glob("source.*") if path.is_file() and path.suffix not in {".part", ".ytdl"}
+ )
+ if not candidates:
+ raise RuntimeError("Не удалось найти скачанный медиафайл")
+ return candidates[0]
+
+
+class JobState:
+ def __init__(self, path: Path, *, asset_id: str, source: str, profile: str) -> None:
+ self.path = path
+ if path.exists():
+ self.value = json.loads(path.read_text(encoding="utf-8"))
+ self.value.update(
+ {
+ "pipeline": PIPELINE_VERSION,
+ "asset_id": asset_id,
+ "source": source,
+ "profile": profile,
+ "status": "running",
+ "current_stage": "received",
+ "stages": {},
+ }
+ )
+ for stale_key in ("error", "failed_at", "completed_at"):
+ self.value.pop(stale_key, None)
+ else:
+ self.value = {
+ "pipeline": PIPELINE_VERSION,
+ "asset_id": asset_id,
+ "source": source,
+ "profile": profile,
+ "status": "running",
+ "current_stage": "received",
+ "stages": {},
+ "created_at": utc_now(),
+ }
+ self.save()
+
+ def stage(self, name: str, status: str, **details: Any) -> None:
+ self.value["current_stage"] = name
+ self.value["status"] = "failed" if status == "failed" else "running"
+ self.value.setdefault("stages", {})[name] = {"status": status, "updated_at": utc_now(), **details}
+ self.save()
+
+ def finish(self) -> None:
+ self.value["status"] = "complete"
+ self.value["current_stage"] = "complete"
+ self.value["completed_at"] = utc_now()
+ self.save()
+
+ def fail(self, error: Exception) -> None:
+ self.value["status"] = "failed"
+ self.value["error"] = str(error)
+ self.value["failed_at"] = utc_now()
+ self.save()
+
+ def save(self) -> None:
+ self.value["updated_at"] = utc_now()
+ atomic_write_json(self.path, self.value)
+
+
+def run_transcription(
+ media: Path,
+ output_dir: Path,
+ *,
+ adapter: Path,
+ engine: str,
+ chunk_seconds: float,
+ overlap_seconds: float,
+ ffmpeg: str,
+ ffprobe: str,
+ force: bool,
+) -> dict[str, Any]:
+ raw_name = "transcript.raw.gigaam.json" if engine in {"auto", "gigaam"} else "transcript.raw.whisper.json"
+ raw_path = output_dir / raw_name
+ if raw_path.exists() and not force:
+ return json.loads(raw_path.read_text(encoding="utf-8"))
+ checkpoint = output_dir / "checkpoints" / "mitim-stt.json"
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
+ command = [
+ sys.executable,
+ str(adapter),
+ "--audio",
+ str(media),
+ "--engine",
+ engine,
+ "--chunk-seconds",
+ str(chunk_seconds),
+ "--overlap-seconds",
+ str(overlap_seconds),
+ "--ffmpeg-path",
+ ffmpeg,
+ "--ffprobe-path",
+ ffprobe,
+ "--checkpoint",
+ str(checkpoint),
+ "--output",
+ str(raw_path),
+ ]
+ run_cmd(command, timeout=24 * 3600)
+ return json.loads(raw_path.read_text(encoding="utf-8"))
+
+
+def canonical_segments(raw: dict[str, Any]) -> list[dict[str, Any]]:
+ result: list[dict[str, Any]] = []
+ for index, source in enumerate(raw.get("segments", [])):
+ start = round(float(source.get("start", 0)), 3)
+ end = round(float(source.get("end", start)), 3)
+ result.append(
+ {
+ "id": index,
+ "start": start,
+ "end": max(start, end),
+ "text": str(source.get("text", "")).strip(),
+ "speaker": source.get("speaker"),
+ "words": source.get("words", []),
+ "confidence": source.get("confidence"),
+ "source": source.get("source", "mitim-stt"),
+ "engine": source.get("engine"),
+ "language": source.get("language"),
+ "quality_flags": list(source.get("quality_flags", [])),
+ }
+ )
+ return result
+
+
+def run_stage_command(
+ template: str,
+ *,
+ media: Path,
+ audio: Path | None = None,
+ transcript: Path,
+ output: Path,
+) -> dict[str, Any]:
+ command = shlex.split(
+ template.format(
+ audio=str(audio or media),
+ media=str(media),
+ transcript=str(transcript),
+ output=str(output),
+ )
+ )
+ run_cmd(command, timeout=24 * 3600)
+ if not output.exists():
+ raise RuntimeError(f"Этап не создал ожидаемый файл: {output}")
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ if not isinstance(payload, dict) or not isinstance(payload.get("segments"), list):
+ raise RuntimeError(f"Некорректный JSON этапа: {output}")
+ return payload
+
+
+def build_qc(segments: list[dict[str, Any]], duration: float) -> dict[str, Any]:
+ gaps: list[dict[str, float]] = []
+ overlaps: list[dict[str, float]] = []
+ previous_end = 0.0
+ for segment in segments:
+ start = float(segment["start"])
+ end = float(segment["end"])
+ if start > previous_end + 0.25:
+ gaps.append({"start": round(previous_end, 3), "end": round(start, 3)})
+ if start < previous_end - 0.25:
+ overlaps.append({"start": round(start, 3), "previous_end": round(previous_end, 3)})
+ previous_end = max(previous_end, end)
+ flagged = [
+ {"id": segment["id"], "start": segment["start"], "flags": segment["quality_flags"]}
+ for segment in segments
+ if segment["quality_flags"]
+ ]
+ return {
+ "status": "review" if flagged or gaps else "ok",
+ "duration": duration,
+ "segment_count": len(segments),
+ "nonempty_segment_count": sum(bool(segment["text"]) for segment in segments),
+ "coverage_end": max((segment["end"] for segment in segments), default=0.0),
+ "gaps": gaps,
+ "overlaps": overlaps,
+ "flagged_segments": flagged,
+ "generated_at": utc_now(),
+ }
+
+
+def export_markdown(
+ path: Path,
+ *,
+ asset_id: str,
+ title: str,
+ source_url: str | None,
+ duration: float,
+ profile: str,
+ segments: list[dict[str, Any]],
+) -> None:
+ lines = [
+ "---",
+ f"asset_id: {asset_id}",
+ f"title: {json.dumps(title, ensure_ascii=False)}",
+ f"duration: {duration}",
+ f"pipeline: {PIPELINE_VERSION}",
+ f"profile: {profile}",
+ "canonical_source: local_asr",
+ f"created_at: {utc_now()}",
+ "---",
+ "",
+ f"# {title}",
+ "",
+ "## Полный локальный транскрипт",
+ "",
+ ]
+ for segment in segments:
+ if not segment["text"]:
+ continue
+ start = float(segment["start"])
+ label = human_timestamp(start)
+ if source_url and is_youtube_url(source_url):
+ heading = f"### [{label}]({source_url}{'&' if '?' in source_url else '?'}t={int(start)})"
+ else:
+ heading = f"### {label}"
+ lines.extend([heading, "", segment["text"], ""])
+ if segment["quality_flags"]:
+ lines.extend([f"_QC: {', '.join(segment['quality_flags'])}_", ""])
+ atomic_write_text(path, "\n".join(lines).strip() + "\n")
+
+
+def export_srt(path: Path, segments: list[dict[str, Any]]) -> None:
+ blocks: list[str] = []
+ cue = 0
+ for segment in segments:
+ if not segment["text"]:
+ continue
+ cue += 1
+ blocks.append(
+ f"{cue}\n{seconds_to_timestamp(segment['start'], srt=True)} --> "
+ f"{seconds_to_timestamp(segment['end'], srt=True)}\n{segment['text']}"
+ )
+ atomic_write_text(path, "\n\n".join(blocks) + ("\n" if blocks else ""))
+
+
+def export_vtt(path: Path, segments: list[dict[str, Any]]) -> None:
+ blocks = ["WEBVTT"]
+ for segment in segments:
+ if not segment["text"]:
+ continue
+ blocks.append(
+ f"{seconds_to_timestamp(segment['start'])} --> {seconds_to_timestamp(segment['end'])}\n{segment['text']}"
+ )
+ atomic_write_text(path, "\n\n".join(blocks) + "\n")
+
+
+def extract_frames(
+ media: Path,
+ output_dir: Path,
+ *,
+ duration: float,
+ interval: int,
+ ffmpeg: str,
+ force: bool,
+) -> list[dict[str, Any]]:
+ if interval <= 0:
+ return []
+ output_dir.mkdir(parents=True, exist_ok=True)
+ existing = sorted(output_dir.glob("frame-*.jpg"))
+ if not existing or force:
+ run_cmd(
+ [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ str(media),
+ "-vf",
+ f"fps=1/{interval},scale=1280:-2",
+ "-q:v",
+ "3",
+ str(output_dir / "frame-%05d.jpg"),
+ ],
+ timeout=7200,
+ )
+ existing = sorted(output_dir.glob("frame-*.jpg"))
+ if not existing and duration > 0:
+ first_frame = output_dir / "frame-00001.jpg"
+ run_cmd(
+ [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ str(media),
+ "-frames:v",
+ "1",
+ "-vf",
+ "scale=1280:-2",
+ "-q:v",
+ "3",
+ str(first_frame),
+ ],
+ timeout=7200,
+ )
+ existing = sorted(output_dir.glob("frame-*.jpg"))
+ return [
+ {
+ "timestamp": round(min(index * interval, duration), 3),
+ "path": str(path),
+ "ocr": None,
+ "description": None,
+ "status": "frame_extracted",
+ }
+ for index, path in enumerate(existing)
+ ]
+
+
+def reusable_visual_timeline(path: Path, *, force: bool) -> dict[str, Any] | None:
+ if force or not path.exists():
+ return None
+ try:
+ timeline = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ frames = timeline.get("frames")
+ if (
+ timeline.get("status") != "complete"
+ or not isinstance(frames, list)
+ or not frames
+ or any(not isinstance(frame, dict) or frame.get("status") != "complete" for frame in frames)
+ ):
+ return None
+ return timeline
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Local-first video transcription around GigaAM/mitim-stt")
+ parser.add_argument("source", help="YouTube URL or local audio/video path")
+ parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
+ parser.add_argument("--profile", choices=tuple(PROFILES), default="standard")
+ parser.add_argument("--engine", choices=("auto", "gigaam", "whisper"), default="gigaam")
+ parser.add_argument("--chunk-seconds", type=float, default=60.0)
+ parser.add_argument("--overlap-seconds", type=float, default=1.5)
+ parser.add_argument("--langs", default="ru,en,en-US,en-GB")
+ parser.add_argument("--title")
+ parser.add_argument("--source-kind", choices=("auto", "youtube", "telegram", "local"), default="auto")
+ parser.add_argument("--stt-adapter", type=Path, default=DEFAULT_STT_ADAPTER)
+ parser.add_argument("--ollama-enricher", type=Path, default=DEFAULT_OLLAMA_ENRICHER)
+ parser.add_argument("--ollama-url", default="http://127.0.0.1:11434")
+ parser.add_argument("--vision-model", default="qwen3-vl:8b")
+ parser.add_argument("--align-command", help="Command template using {audio}, {transcript}, {output}")
+ parser.add_argument("--diarize-command", help="Command template using {audio}, {transcript}, {output}")
+ parser.add_argument("--ffmpeg-path", type=Path, help="Absolute path to ffmpeg for detached workers")
+ parser.add_argument("--ffprobe-path", type=Path, help="Absolute path to ffprobe for detached workers")
+ parser.add_argument("--skip-vision-analysis", action="store_true")
+ parser.add_argument("--skip-summary", action="store_true")
+ parser.add_argument("--telegram-chat-id", help="Build a Telegram Suite publication outbox for this chat")
+ parser.add_argument("--telegram-source-topic-id", type=int, help="Topic where the source was received")
+ parser.add_argument("--telegram-connection", default=DEFAULT_TELEGRAM_CONNECTION)
+ parser.add_argument("--force", action="store_true")
+ args = parser.parse_args()
+
+ if args.telegram_source_topic_id is not None and not args.telegram_chat_id:
+ parser.error("--telegram-source-topic-id требует --telegram-chat-id")
+
+ ffmpeg = require_binary("ffmpeg", args.ffmpeg_path)
+ ffprobe = require_binary("ffprobe", args.ffprobe_path)
+ source_is_youtube = is_youtube_url(args.source)
+ if urlparse(args.source).scheme in {"http", "https"} and not source_is_youtube:
+ raise ValueError("В первой версии URL поддерживаются только для YouTube; остальные источники передавайте файлом")
+
+ profile = PROFILES[args.profile]
+ metadata: dict[str, Any]
+ source_info: dict[str, Any]
+ output_dir: Path
+ work_dir: Path
+
+ if source_is_youtube:
+ yt_dlp = require_binary("yt-dlp")
+ metadata = json.loads(run_cmd([yt_dlp, "--dump-single-json", "--no-playlist", "--no-warnings", args.source]))
+ asset_id = str(metadata.get("id") or safe_name(args.source, "youtube"))
+ output_dir = args.output_root / asset_id
+ work_dir = output_dir / "_work"
+ title = args.title or str(metadata.get("title") or asset_id)
+ source_info = {
+ "kind": "youtube",
+ "original": args.source,
+ "canonical_url": metadata.get("webpage_url") or args.source,
+ "video_id": asset_id,
+ "received_at": utc_now(),
+ }
+ else:
+ local_source = Path(args.source).expanduser().resolve(strict=True)
+ digest = file_sha256(local_source)
+ asset_id = safe_name(local_source.stem, digest[:16]) + "-" + digest[:12]
+ output_dir = args.output_root / asset_id
+ work_dir = output_dir / "_work"
+ title = args.title or local_source.stem
+ metadata = {"title": title, "id": asset_id}
+ source_info = {
+ "kind": "telegram" if args.source_kind == "telegram" else "local",
+ "original": str(local_source),
+ "sha256": digest,
+ "size": local_source.stat().st_size,
+ "received_at": utc_now(),
+ }
+
+ output_dir.mkdir(parents=True, exist_ok=True)
+ job = JobState(output_dir / "job-state.json", asset_id=asset_id, source=args.source, profile=args.profile)
+
+ try:
+ job.stage("metadata", "running")
+ atomic_write_json(output_dir / "metadata.json", metadata)
+ atomic_write_json(output_dir / "source-info.json", source_info)
+ job.stage("metadata", "complete")
+
+ if source_is_youtube:
+ job.stage("media", "running")
+ media = download_youtube_media(args.source, work_dir, bool(profile["visuals"]), ffmpeg=ffmpeg)
+ caption = choose_youtube_caption(metadata, [item.strip() for item in args.langs.split(",") if item.strip()])
+ if caption:
+ caption_source, caption_language = caption
+ try:
+ caption_path = download_youtube_caption(
+ args.source,
+ work_dir / "captions",
+ caption_source,
+ caption_language,
+ ffmpeg=ffmpeg,
+ )
+ shutil.copy2(caption_path, output_dir / "subtitles.raw.vtt")
+ atomic_write_json(
+ output_dir / "subtitles.source.json",
+ {"source": caption_source, "language": caption_language, "canonical": False},
+ )
+ except Exception as error:
+ atomic_write_json(output_dir / "subtitles.source.json", {"status": "failed", "error": str(error)})
+ job.stage("media", "complete", path=str(media))
+ else:
+ media = Path(args.source).expanduser().resolve(strict=True)
+ job.stage("media", "complete", path=str(media))
+
+ media_probe = probe_media(media, ffprobe=ffprobe)
+ if not media_probe["has_audio"]:
+ raise RuntimeError("В источнике не найден аудиопоток")
+ atomic_write_json(output_dir / "media-info.json", media_probe)
+
+ effective_engine = "whisper" if args.profile == "multilingual" and args.engine == "gigaam" else args.engine
+ job.stage("transcription", "running", engine=effective_engine)
+ raw = run_transcription(
+ media,
+ output_dir,
+ adapter=args.stt_adapter,
+ engine=effective_engine,
+ chunk_seconds=args.chunk_seconds,
+ overlap_seconds=args.overlap_seconds,
+ ffmpeg=ffmpeg,
+ ffprobe=ffprobe,
+ force=args.force,
+ )
+ segments = canonical_segments(raw)
+ transcript_path = output_dir / "transcript.json"
+ transcript = {
+ "asset_id": asset_id,
+ "title": title,
+ "source": source_info,
+ "duration": media_probe["duration"],
+ "language": "ru" if effective_engine == "gigaam" else "auto",
+ "canonical_source": "local_asr",
+ "pipeline": PIPELINE_VERSION,
+ "profile": args.profile,
+ "asr": raw.get("metadata", {}),
+ "created_at": utc_now(),
+ "segments": segments,
+ }
+ atomic_write_json(transcript_path, transcript)
+ job.stage("transcription", "complete", segment_count=len(segments))
+
+ stage_audio = media
+ needs_stage_audio = (profile["alignment"] and bool(args.align_command)) or (
+ profile["diarization"] and bool(args.diarize_command)
+ )
+ if needs_stage_audio:
+ stage_audio = prepare_stage_audio(
+ media,
+ work_dir / "stage-audio-16k-mono.wav",
+ ffmpeg=ffmpeg,
+ force=args.force,
+ )
+
+ if profile["alignment"]:
+ if args.align_command:
+ job.stage("alignment", "running")
+ aligned_path = output_dir / "transcript.aligned.json"
+ aligned = run_stage_command(
+ args.align_command,
+ media=media,
+ audio=stage_audio,
+ transcript=transcript_path,
+ output=aligned_path,
+ )
+ segments = canonical_segments(aligned)
+ transcript["segments"] = segments
+ transcript["alignment"] = aligned.get("metadata", {"status": "complete"})
+ atomic_write_json(transcript_path, transcript)
+ job.stage("alignment", "complete")
+ else:
+ job.stage("alignment", "skipped", reason="align_command_not_configured", precision="chunk")
+ else:
+ job.stage("alignment", "skipped", reason="profile")
+
+ if profile["diarization"]:
+ if args.diarize_command:
+ job.stage("diarization", "running")
+ diarized_path = output_dir / "transcript.diarized.json"
+ diarized = run_stage_command(
+ args.diarize_command,
+ media=media,
+ audio=stage_audio,
+ transcript=transcript_path,
+ output=diarized_path,
+ )
+ segments = canonical_segments(diarized)
+ transcript["segments"] = segments
+ transcript["diarization"] = diarized.get(
+ "diarization",
+ diarized.get("metadata", {"status": "complete"}),
+ )
+ if isinstance(diarized.get("speakers"), list):
+ transcript["speakers"] = diarized["speakers"]
+ if isinstance(diarized.get("speaker_turns"), list):
+ transcript["speaker_turns"] = diarized["speaker_turns"]
+ atomic_write_json(transcript_path, transcript)
+ job.stage("diarization", "complete")
+ else:
+ job.stage("diarization", "skipped", reason="diarize_command_not_configured")
+ else:
+ job.stage("diarization", "skipped", reason="profile")
+
+ visual_timeline_path = output_dir / "visual-timeline.json"
+ if profile["visuals"] and media_probe["has_video"]:
+ existing_timeline = reusable_visual_timeline(visual_timeline_path, force=args.force)
+ if existing_timeline:
+ existing_frames = existing_timeline["frames"]
+ job.stage(
+ "visuals",
+ "complete",
+ frame_count=len(existing_frames),
+ analyzed_frame_count=len(existing_frames),
+ ocr="complete",
+ model=existing_timeline.get("model") or args.vision_model,
+ resumed=True,
+ )
+ else:
+ job.stage("visuals", "running")
+ frames = extract_frames(
+ media,
+ output_dir / "frames",
+ duration=media_probe["duration"],
+ interval=int(profile["frame_interval"]),
+ ffmpeg=ffmpeg,
+ force=args.force,
+ )
+ atomic_write_json(
+ visual_timeline_path,
+ {
+ "interval_seconds": profile["frame_interval"],
+ "frames": frames,
+ "status": "frames_extracted",
+ },
+ )
+ if frames and not args.skip_vision_analysis:
+ enrich_command = [
+ sys.executable,
+ str(args.ollama_enricher),
+ "--ollama-url",
+ args.ollama_url,
+ "--model",
+ args.vision_model,
+ "visuals",
+ "--timeline",
+ str(visual_timeline_path),
+ ]
+ if args.force:
+ enrich_command.append("--force")
+ try:
+ run_cmd(enrich_command, timeout=7200)
+ job.stage(
+ "visuals",
+ "complete",
+ frame_count=len(frames),
+ analyzed_frame_count=len(frames),
+ ocr="complete",
+ model=args.vision_model,
+ )
+ except Exception as error:
+ if args.profile == "deep":
+ raise
+ job.stage(
+ "visuals",
+ "partial",
+ frame_count=len(frames),
+ ocr="failed",
+ model=args.vision_model,
+ error=str(error),
+ )
+ else:
+ job.stage(
+ "visuals",
+ "complete",
+ frame_count=len(frames),
+ analyzed_frame_count=0,
+ ocr="skipped" if args.skip_vision_analysis else "not_applicable",
+ )
+ else:
+ job.stage("visuals", "skipped", reason="profile_or_no_video_stream")
+
+ if args.skip_summary:
+ job.stage("summary", "skipped", reason="command_line")
+ else:
+ job.stage("summary", "running", model=args.vision_model)
+ summary_command = [
+ sys.executable,
+ str(args.ollama_enricher),
+ "--ollama-url",
+ args.ollama_url,
+ "--model",
+ args.vision_model,
+ "summary",
+ "--transcript",
+ str(transcript_path),
+ "--output-json",
+ str(output_dir / "summary.json"),
+ "--output-markdown",
+ str(output_dir / "summary.md"),
+ ]
+ if visual_timeline_path.exists():
+ summary_command.extend(["--visual-timeline", str(visual_timeline_path)])
+ if args.force:
+ summary_command.append("--force")
+ run_cmd(summary_command, timeout=7200)
+ summary_result = json.loads((output_dir / "summary.json").read_text(encoding="utf-8"))
+ job.stage(
+ "summary",
+ "complete",
+ model=args.vision_model,
+ chapter_count=len(summary_result.get("chapters", [])),
+ visual_finding_count=len(summary_result.get("visual_findings", [])),
+ )
+
+ job.stage("exports", "running")
+ qc = build_qc(segments, media_probe["duration"])
+ atomic_write_json(output_dir / "qc.json", qc)
+ export_markdown(
+ output_dir / "transcript.md",
+ asset_id=asset_id,
+ title=title,
+ source_url=source_info.get("canonical_url"),
+ duration=media_probe["duration"],
+ profile=args.profile,
+ segments=segments,
+ )
+ export_srt(output_dir / "transcript.srt", segments)
+ export_vtt(output_dir / "transcript.vtt", segments)
+ artifacts = [
+ "metadata.json",
+ "source-info.json",
+ "media-info.json",
+ "transcript.json",
+ "transcript.md",
+ "transcript.srt",
+ "transcript.vtt",
+ "qc.json",
+ "job-state.json",
+ ]
+ if (output_dir / "visual-timeline.json").exists():
+ artifacts.append("visual-timeline.json")
+ if (output_dir / "frames").exists():
+ artifacts.append("frames/")
+ if (output_dir / "summary.json").exists():
+ artifacts.append("summary.json")
+ if (output_dir / "summary.md").exists():
+ artifacts.append("summary.md")
+ manifest = {
+ "asset_id": asset_id,
+ "title": title,
+ "status": "complete",
+ "pipeline": PIPELINE_VERSION,
+ "profile": args.profile,
+ "canonical_source": "local_asr",
+ "engine": effective_engine,
+ "duration": media_probe["duration"],
+ "segment_count": len(segments),
+ "qc_status": qc["status"],
+ "vision_status": job.value.get("stages", {}).get("visuals", {}).get("status"),
+ "summary_status": job.value.get("stages", {}).get("summary", {}).get("status"),
+ "publication_status": "pending" if args.telegram_chat_id else "skipped",
+ "vision_model": args.vision_model,
+ "artifacts": artifacts,
+ "completed_at": utc_now(),
+ }
+ atomic_write_json(output_dir / "manifest.json", manifest)
+ job.stage("exports", "complete")
+
+ if args.telegram_chat_id:
+ job.stage("publication", "running", connection=args.telegram_connection)
+ publication_path = output_dir / "telegram-publication.json"
+ publication = build_publication_plan(
+ output_dir,
+ chat_id=args.telegram_chat_id,
+ source_topic_id=args.telegram_source_topic_id,
+ connection=args.telegram_connection,
+ output=publication_path,
+ )
+ artifacts.append("telegram-publication.json")
+ manifest["artifacts"] = artifacts
+ manifest["publication_status"] = "ready"
+ atomic_write_json(output_dir / "manifest.json", manifest)
+ job.stage(
+ "publication",
+ "ready",
+ outbox=str(publication_path),
+ topic_name=publication["topic"]["name"],
+ )
+ else:
+ job.stage("publication", "skipped", reason="telegram_chat_not_configured")
+ job.finish()
+ print(json.dumps({"output_dir": str(output_dir), "manifest": manifest}, ensure_ascii=False, indent=2))
+ return 0
+ except Exception as error:
+ job.fail(error)
+ raise
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From af52fa18a9d18f4f56c7a77b4ed5d43eb2f67d83 Mon Sep 17 00:00:00 2001
From: mitimaicode <309470861+mitimaicode@users.noreply.github.com>
Date: Mon, 21 Sep 2026 17:14:33 +0000
Subject: [PATCH 2/2] fix: install OpenClaw plugin without peer tree
---
.github/workflows/ci.yml | 2 +-
CONTRIBUTING.md | 2 +-
server/linux/install.sh | 6 +++++-
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a40420b..d23bc59 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -114,7 +114,7 @@ jobs:
- name: Validate optional OpenClaw plugin
working-directory: server/linux/openclaw-plugin
run: |
- npm ci --omit=dev --omit=peer --ignore-scripts
+ npm ci --omit=dev --omit=peer --ignore-scripts --legacy-peer-deps
node --check index.js
node --test \
index.test.js \
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1593548..f7c7ac8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,7 +21,7 @@ python3 -m py_compile worker/asr_worker.py worker/media_worker.py worker/text_wo
python3 -m unittest discover -s tests -v
bash -n server/linux/install.sh scripts/package_server_release.sh
PYTHONPATH=server/linux/video python3 -m unittest discover -s server/linux/tests -v
-(cd server/linux/openclaw-plugin && npm ci --omit=dev --omit=peer --ignore-scripts && node --test index.test.js source-validation.test.js tool-result.test.js worker-scope.test.js)
+(cd server/linux/openclaw-plugin && npm ci --omit=dev --omit=peer --ignore-scripts --legacy-peer-deps && node --test index.test.js source-validation.test.js tool-result.test.js worker-scope.test.js)
```
Для изменения распознавания желательно приложить обезличенный набор тестовых
diff --git a/server/linux/install.sh b/server/linux/install.sh
index ea3ddf6..ca9d03c 100755
--- a/server/linux/install.sh
+++ b/server/linux/install.sh
@@ -139,7 +139,11 @@ if ((INSTALL_OPENCLAW)); then
PLUGIN_ROOT="${HOME}/.openclaw/workspace/plugins/video-transcription-taskflow"
mkdir -p "$PLUGIN_ROOT"
cp -R "$SOURCE_ROOT/openclaw-plugin/." "$PLUGIN_ROOT/"
- npm --prefix "$PLUGIN_ROOT" install --omit=dev --omit=peer --ignore-scripts
+ npm --prefix "$PLUGIN_ROOT" install \
+ --omit=dev \
+ --omit=peer \
+ --ignore-scripts \
+ --legacy-peer-deps
fi
systemctl --user daemon-reload