auto-sync: 2026-04-30 00:30:01
This commit is contained in:
@@ -147,3 +147,5 @@
|
||||
{"op": "create", "entity": {"id": "doc_fr24_rtl_sdr_architecture", "type": "Document", "properties": {"title": "FR24 RTL-SDR architecture", "path": "tasks/flightradar24/docs/ARCHITECTURE.md", "summary": "Контейнерная архитектура ingest-контура RTL-SDR для FR24 / noisemap."}, "created": "2026-04-18T21:11:00Z"}, "timestamp": "2026-04-18T21:11:00Z"}
|
||||
{"op": "create", "entity": {"id": "doc_fr24_rtl_sdr_tz", "type": "Document", "properties": {"title": "FR24 RTL-SDR TZ", "path": "tasks/flightradar24/docs/RTL-SDR_TZ.md", "summary": "ТЗ на приём, хранение и обработку данных с RTL-SDR для FR24 / noisemap."}, "created": "2026-04-18T21:11:00Z"}, "timestamp": "2026-04-18T21:11:00Z"}
|
||||
{"op": "create", "entity": {"id": "doc_fr24_test_plan", "type": "Document", "properties": {"title": "FR24 RTL-SDR test plan", "path": "tasks/flightradar24/docs/TEST_PLAN.md", "summary": "Smoke, integration, recovery and retention checks for the RTL-SDR ingest stack."}, "created": "2026-04-18T21:18:00Z"}, "timestamp": "2026-04-18T21:18:00Z"}
|
||||
{"op":"create","entity":{"id":"proj_karaoke","type":"Project","properties":{"name":"Караоке-генератор","status":"planning","folder":"tasks/karaoke/","doc_path":"tasks/karaoke/PROJECT.md","description":"Генерация видео-караоке из аудиофайла: транскрипция текста, NLP-анализ смысла, авто-подбор видео-фона из стоков, рендер в MP4.","start_date":"2026-04-29"},"created":"2026-04-29T21:21:00Z"},"timestamp":"2026-04-29T21:21:00Z"}
|
||||
{"op":"create","entity":{"id":"task_karaoke_setup","type":"Task","properties":{"title":"Оформить проект Караоке-генератор","status":"open","project":"proj_karaoke","folder":"tasks/karaoke/TASKS/active/project-setup/","doc_path":"tasks/karaoke/TASKS/active/project-setup/TASK.md","description":"Создать структуру папок, PROJECT.md, TASK.md, обновить онтологию.","priority":"high"},"created":"2026-04-29T21:21:00Z"},"timestamp":"2026-04-29T21:21:00Z"}
|
||||
|
||||
97
tasks/karaoke/PROJECT.md
Normal file
97
tasks/karaoke/PROJECT.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Караоке-генератор — Бизнес требования
|
||||
|
||||
## Что делаем
|
||||
Пользователь загружает **аудиофайл песни** → получает **видео-караоке** с анимированным текстом, синхронизированным с аудио.
|
||||
|
||||
---
|
||||
|
||||
## Вход
|
||||
|
||||
### Обязательно
|
||||
- **Аудиофайл**: mp3, wav, ogg, m4a (любая длина)
|
||||
|
||||
### Опционально
|
||||
- **Текст песни**: если пользователь хочет конкретный текст
|
||||
- Ручной ввод (textarea)
|
||||
- Загрузка файла: txt, lrc, srt, vtt
|
||||
- Без текста → автоматическая транскрипция через Whisper
|
||||
|
||||
---
|
||||
|
||||
## Выход
|
||||
- **Видеофайл**: mp4 (H.264), с встроенным аудио
|
||||
- Разрешение: 720p / 1080p
|
||||
- Фреймрейт: 30fps
|
||||
- Текст синхронизирован с аудио
|
||||
- Стилизация: шрифт, цвет, анимации
|
||||
|
||||
---
|
||||
|
||||
## Основной функционал
|
||||
|
||||
### 1. Транскрипция и тайминги
|
||||
- Автоматическая транскрипция через Whisper API (если текст не загружен)
|
||||
- Поддержка LRC, SRT, WebVTT
|
||||
- Ручная подгонка таймингов (если автомат не попал)
|
||||
|
||||
### 2. Анализ смысла песни (для видео-фона)
|
||||
- Извлечение ключевых слов и тем из текста (NLP через GigaChat)
|
||||
- Классификация настроения: грусть, радость, любовь, природа, город, ночь и т.д.
|
||||
- Определение тематических сцен: «закат», «дождь в городе», «горы», «лес»
|
||||
|
||||
### 3. Автоматический подбор видео-фона
|
||||
- Поиск по стоковым видео: **Pexels Video**, **Pixabay Video** (бесплатно, CC0)
|
||||
- Соответствие настроение/сцена → видео-клип
|
||||
- Зацикливание коротких клипов для длинных песен
|
||||
- Плавные переходы между клипами (crossfade)
|
||||
- Опционально: ручная замена фона
|
||||
|
||||
### 4. Сборка финального видео
|
||||
- Текст поверх видео-фона
|
||||
- Подсветка текущей строки
|
||||
- Fade-in/out анимации
|
||||
- Аудио встраивается в видео
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Стек
|
||||
- **Транскрипция**: OpenAI Whisper API
|
||||
- **Анализ текста**: GigaChat (через наш прокси)
|
||||
- **Поиск видео**: Pexels API / Pixabay API
|
||||
- **Рендер видео**: FFmpeg + MoviePy / PyAV
|
||||
|
||||
### Ограничения
|
||||
- Автоматический подбор фона не идеален — может потребовать ручной корректировки
|
||||
- Длинные песни (>10 мин) рендерятся дольше
|
||||
- Качество транскрипции зависит от чистоты аудио
|
||||
- API сток-видео имеют лимиты запросов
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### v0.1 — MVP
|
||||
- Загрузка аудио + текст (или транскрипция)
|
||||
- Ручной выбор видео-фона
|
||||
- Рендер в 720p
|
||||
- Тайминги через Whisper
|
||||
|
||||
### v0.2 — Авто-фон
|
||||
- NLP анализ смысла
|
||||
- Автоматический подбор видео из стоков
|
||||
- 1080p
|
||||
|
||||
### v0.3 — Улучшения
|
||||
- Ручная коррекция таймингов в UI
|
||||
- Дуэт (два текста разными цветами)
|
||||
- Экспорт SRT/LRC
|
||||
- Предпросмотр перед рендером
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
- **Проект**: karaoke
|
||||
- **Слаг**: `karaoke`
|
||||
- **Папка**: `tasks/karaoke/`
|
||||
110
tasks/karaoke/TASKS/active/project-setup/TASK.md
Normal file
110
tasks/karaoke/TASKS/active/project-setup/TASK.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# ТЗ: Караоке-генератор — MVP (v0.1)
|
||||
|
||||
## Цель
|
||||
Реализовать CLI-инструмент, который принимает аудиофайл песни и генерирует видео-файл с синхронизированным текстом (стиль "субтитры/караоке").
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
- **Python 3.10+**
|
||||
- **Whisper** (openai-whisper или faster-whisper) — транскрипция + тайминги
|
||||
- **FFmpeg** — рендер финального видео
|
||||
- **MoviePy** или **ffmpeg-python** — наложение текста на видео
|
||||
- **Pillow** — рендер текстовых кадров (если MoviePy не справляется)
|
||||
- **Pexels API** — поиск стоковых видео-фонов
|
||||
- **GigaChat API** (через прокси `185.130.212.192:8443`) — NLP-анализ смысла текста
|
||||
|
||||
---
|
||||
|
||||
## Функционал MVP (v0.1)
|
||||
|
||||
### Вход
|
||||
```
|
||||
python karaoke.py --audio song.mp3 [--text lyrics.txt] [--output output.mp4]
|
||||
```
|
||||
- `--audio` — обязательный аргумент, путь к аудиофайлу (mp3, wav, ogg, m4a)
|
||||
- `--text` — опционально, путь к файлу с текстом (txt, lrc, srt)
|
||||
- `--output` — опционально, путь к выходному файлу (по умолчанию `output.mp4`)
|
||||
|
||||
### Шаг 1: Транскрипция
|
||||
- Если `--text` не передан → запустить Whisper на аудио
|
||||
- Whisper возвращает сегменты с таймингами `[{start, end, text}]`
|
||||
- Если `--text` передан:
|
||||
- `.lrc` / `.srt` → парсить тайминги напрямую
|
||||
- `.txt` → выровнять текст по аудио через Whisper (forced alignment)
|
||||
|
||||
### Шаг 2: NLP-анализ (для фона)
|
||||
- Отправить полный текст в GigaChat
|
||||
- Промпт: "Определи настроение и 3-5 ключевых визуальных сцен для этой песни. Ответь JSON: {mood: str, scenes: [str]}"
|
||||
- Пример ответа: `{"mood": "romantic", "scenes": ["sunset beach", "city lights", "rain"]}`
|
||||
|
||||
### Шаг 3: Подбор видео-фона
|
||||
- Взять первую сцену из `scenes`
|
||||
- Запрос к Pexels API: `GET /videos/search?query={scene}&per_page=3`
|
||||
- Скачать первый подходящий клип (HD, landscape)
|
||||
- Зациклить клип до длины аудио (loop)
|
||||
- Если Pexels не вернул результат → использовать чёрный фон
|
||||
|
||||
### Шаг 4: Рендер видео
|
||||
- Разрешение: 1280x720
|
||||
- Фреймрейт: 30fps
|
||||
- Фон: видео-клип (зациклен)
|
||||
- Текст: текущая строка по центру внизу экрана
|
||||
- Шрифт: белый, размер 48px, с чёрной тенью
|
||||
- Подсветка активной строки: жёлтый цвет
|
||||
- Fade-in/out: 0.3 сек
|
||||
- Аудио: оригинальный файл встроен в видео
|
||||
- Формат выхода: mp4 (H.264 + AAC)
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
```
|
||||
tasks/karaoke/
|
||||
├── PROJECT.md
|
||||
├── karaoke.py # точка входа
|
||||
├── transcribe.py # Whisper транскрипция
|
||||
├── nlp.py # GigaChat NLP-анализ
|
||||
├── video_bg.py # Pexels API + скачивание фона
|
||||
├── render.py # FFmpeg/MoviePy рендер
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация / секреты
|
||||
Читать из переменных окружения (или из `~/.openclaw/.env`):
|
||||
- `PEXELS_API_KEY` — ключ Pexels API (нужно получить на pexels.com/api)
|
||||
- `GIGACHAT_BASE_URL` — `https://185.130.212.192:8443` (прокси)
|
||||
- `OPENAI_API_KEY` — если используется OpenAI Whisper API (опционально, можно local)
|
||||
|
||||
---
|
||||
|
||||
## Ограничения MVP
|
||||
- Только один видео-фон на всю песню (не меняется по сценам)
|
||||
- Нет UI — только CLI
|
||||
- Нет предпросмотра
|
||||
- Рендер может занять 1-5 мин для песни 3-5 мин
|
||||
|
||||
---
|
||||
|
||||
## Критерии готовности
|
||||
- [ ] `python karaoke.py --audio test.mp3` → генерирует `output.mp4`
|
||||
- [ ] Текст синхронизирован с аудио (±0.5 сек)
|
||||
- [ ] Видео-фон подобран автоматически (или чёрный если API недоступен)
|
||||
- [ ] Файл воспроизводится в VLC/браузере без ошибок
|
||||
- [ ] `requirements.txt` заполнен, `README.md` с инструкцией запуска
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ нужно в MVP
|
||||
- UI/веб-интерфейс
|
||||
- Смена фона по сценам
|
||||
- Дуэт (два текста)
|
||||
- Экспорт SRT/LRC
|
||||
- Ручная коррекция таймингов
|
||||
|
||||
---
|
||||
|
||||
_Создано: 2026-04-29 | Проект: proj_karaoke_
|
||||
123
tasks/karaoke/nlp.py
Normal file
123
tasks/karaoke/nlp.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
nlp.py — GigaChat NLP-анализ текста песни → {mood, scenes}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
|
||||
|
||||
|
||||
def get_gigachat_token() -> str:
|
||||
"""Получить access_token GigaChat через GigaChat API."""
|
||||
base_url = os.environ.get("GIGACHAT_BASE_URL", "https://gigachat.devices.sberbank.ru")
|
||||
# По ТЗ: через прокси 185.130.212.192:8443
|
||||
creds = os.environ.get("GIGACHAT_CREDS", "")
|
||||
|
||||
token_url = os.environ.get("GIGACHAT_TOKEN_URL", f"{base_url}/api/v2/oauth")
|
||||
headers = {
|
||||
"Authorization": f"Basic {creds}",
|
||||
"RqUID": "00000000-0000-0000-0000-000000000000",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
resp = requests.post(token_url, headers=headers, data={"scope": "GIGACHAT_API_PERS"},
|
||||
verify=False, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def analyze(text: str) -> dict:
|
||||
"""
|
||||
Отправить текст в GigaChat, получить {mood, scenes}.
|
||||
При ошибке возвращает fallback.
|
||||
"""
|
||||
try:
|
||||
base_url = os.environ.get("GIGACHAT_BASE_URL", "https://gigachat.devices.sberbank.ru")
|
||||
token = get_gigachat_token()
|
||||
except Exception as e:
|
||||
print(f"[nlp] GigaChat недоступен: {e}. Используем fallback.")
|
||||
return _fallback(text)
|
||||
|
||||
prompt = (
|
||||
"Определи настроение и 3-5 ключевых визуальных сцен для этой песни. "
|
||||
"Ответь ТОЛЬКО JSON без обёрток:\n"
|
||||
'{"mood": "строка", "scenes": ["сцена1", "сцена2", ...]}'
|
||||
"\n\nТекст песни:\n" + text[:3000]
|
||||
)
|
||||
|
||||
url = f"{base_url}/api/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = {
|
||||
"model": "GigaChat",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=body,
|
||||
verify=False, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Парсим JSON из ответа — иногда модель возвращает markdown-обёртку
|
||||
content = content.strip().strip("```json").strip("```").strip()
|
||||
result = json.loads(content)
|
||||
print(f"[nlp] GigaChat ответ: mood={result.get('mood')}, scenes={result.get('scenes')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[nlp] Ошибка GigaChat API: {e}. Используем fallback.")
|
||||
return _fallback(text)
|
||||
|
||||
|
||||
def _fallback(text: str) -> dict:
|
||||
"""Простой fallback без API."""
|
||||
text_lower = text.lower()
|
||||
|
||||
mood_map = {
|
||||
"love": "romantic", "любов": "romantic", "heart": "romantic",
|
||||
"сердц": "romantic", "kiss": "romantic", "night": "moody",
|
||||
"ноч": "moody", "dark": "moody", "темн": "moody",
|
||||
"rain": "moody", "дожд": "moody", "sun": "happy",
|
||||
"солнц": "happy", "свет": "happy", "light": "happy",
|
||||
"party": "energetic", "танц": "energetic", "dance": "energetic",
|
||||
"drive": "energetic", "драйв": "energetic",
|
||||
"sad": "sad", "груст": "sad", "cry": "sad", "плач": "sad",
|
||||
}
|
||||
|
||||
mood = "neutral"
|
||||
for key, val in mood_map.items():
|
||||
if key in text_lower:
|
||||
mood = val
|
||||
break
|
||||
|
||||
scene_map = {
|
||||
"love": "romantic couple", "любов": "romantic sunset",
|
||||
"night": "city night lights", "ноч": "starry sky",
|
||||
"sun": "golden hour landscape", "солнц": "sunrise nature",
|
||||
"rain": "rain window", "дожд": "rainy city",
|
||||
"party": "party lights", "танц": "dance floor",
|
||||
"sad": "solitary person", "груст": "lonely road",
|
||||
"sea": "ocean waves", "мор": "ocean sunset",
|
||||
"mountain": "mountain peaks", "гор": "mountain landscape",
|
||||
"fire": "campfire", "огон": "firelight",
|
||||
"snow": "snowy landscape", "снег": "winter forest",
|
||||
"лес": "forest path", "forest": "forest path",
|
||||
"road": "highway drive", "дорог": "open road",
|
||||
}
|
||||
|
||||
scenes = ["abstract gradient"]
|
||||
for key, val in scene_map.items():
|
||||
if key in text_lower:
|
||||
scenes.append(val)
|
||||
|
||||
scenes = scenes[:5]
|
||||
if len(scenes) < 1:
|
||||
scenes = ["abstract gradient"]
|
||||
|
||||
return {"mood": mood, "scenes": scenes}
|
||||
7
tasks/karaoke/requirements.txt
Normal file
7
tasks/karaoke/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
faster-whisper==1.2.1
|
||||
moviepy==2.1.2
|
||||
pillow==10.4.0
|
||||
requests==2.33.1
|
||||
numpy==2.4.0
|
||||
tqdm==4.67.3
|
||||
python-dotenv==1.2.2
|
||||
203
tasks/karaoke/transcribe.py
Normal file
203
tasks/karaoke/transcribe.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
transcribe.py — Whisper транскрипция аудио → [{start, end, text}]
|
||||
|
||||
Поддержка:
|
||||
- faster-whisper (CPU) по умолчанию
|
||||
- Готовые файлы .lrc, .srt (парсинг таймингов)
|
||||
- Простой .txt (forced alignment через Whisper word-timestamps)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whisper транскрипция
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def transcribe_whisper(audio_path: str, model_size: str = "base",
|
||||
language: Optional[str] = None,
|
||||
device: str = "cpu") -> list[dict]:
|
||||
"""Запуск faster-whisper. Возвращает список сегментов [{start, end, text}]."""
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError:
|
||||
print("[transcribe] faster-whisper не установлен. Ставим...", flush=True)
|
||||
os.system(f"{sys.executable} -m pip install faster-whisper")
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
model = WhisperModel(model_size, device=device, compute_type="int8")
|
||||
segments, _ = model.transcribe(audio_path, language=language,
|
||||
word_timestamps=True)
|
||||
|
||||
results = []
|
||||
for seg in segments:
|
||||
text = seg.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
results.append({
|
||||
"start": round(seg.start, 2),
|
||||
"end": round(seg.end, 2),
|
||||
"text": text,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Парсинг .lrc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RE_LRC = re.compile(r"\[(\d{2,}):(\d{2})\.(\d{2,3})\](.*)")
|
||||
|
||||
def parse_lrc(path: str) -> list[dict]:
|
||||
lines = []
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
m = _RE_LRC.match(line.strip())
|
||||
if not m:
|
||||
continue
|
||||
mins, secs, frac, text = m.groups()
|
||||
frac = frac.ljust(3, "0")[:3]
|
||||
timestamp = int(mins) * 60 + int(secs) + int(frac) / 1000
|
||||
text = text.strip()
|
||||
if text:
|
||||
lines.append({"start": round(timestamp, 2),
|
||||
"end": None, # заполним позже
|
||||
"text": text})
|
||||
|
||||
# Длительность каждой строки = начало следующей
|
||||
for i in range(len(lines) - 1):
|
||||
lines[i]["end"] = lines[i + 1]["start"]
|
||||
if lines:
|
||||
lines[-1]["end"] = lines[-1]["start"] + 4.0
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Парсинг .srt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RE_SRT_TIME = re.compile(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})")
|
||||
|
||||
def _srt_ts_to_sec(h, m, s, ms):
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
||||
|
||||
|
||||
def parse_srt(path: str) -> list[dict]:
|
||||
results = []
|
||||
blocks = re.split(r"\n\s*\n", Path(path).read_text(encoding="utf-8").strip())
|
||||
for block in blocks:
|
||||
parts = block.strip().splitlines()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
m_from = _RE_SRT_TIME.match(parts[1].split(" --> ")[0].strip())
|
||||
m_to = _RE_SRT_TIME.match(parts[1].split(" --> ")[1].strip())
|
||||
if not m_from or not m_to:
|
||||
continue
|
||||
start = _srt_ts_to_sec(*m_from.groups())
|
||||
end = _srt_ts_to_sec(*m_to.groups())
|
||||
text = " ".join(parts[2:]).strip()
|
||||
if text:
|
||||
results.append({"start": round(start, 2),
|
||||
"end": round(end, 2),
|
||||
"text": text})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .txt forced alignment через whisper word-timestamps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def align_txt(audio_path: str, txt_path: str,
|
||||
model_size: str = "base",
|
||||
device: str = "cpu") -> list[dict]:
|
||||
"""Align plain .txt lyrics to audio by splitting Whisper segments by lines."""
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
txt_lines = Path(txt_path).read_text(encoding="utf-8").splitlines()
|
||||
txt_lines = [l.strip() for l in txt_lines if l.strip()]
|
||||
total_lines = len(txt_lines)
|
||||
if total_lines == 0:
|
||||
return []
|
||||
|
||||
model = WhisperModel(model_size, device=device, compute_type="int8")
|
||||
segments, _ = model.transcribe(audio_path, word_timestamps=True)
|
||||
|
||||
# Собираем полнотекст из whisper
|
||||
whisper_parts = []
|
||||
for seg in segments:
|
||||
whisper_parts.append(seg.text.strip())
|
||||
full_whisper = " ".join(whisper_parts)
|
||||
|
||||
segment_lines = max(total_lines, len(whisper_parts))
|
||||
|
||||
# Равномерно распределяем строки по whisper-сегментам
|
||||
if len(whisper_parts) == 0:
|
||||
return []
|
||||
|
||||
results = []
|
||||
line_idx = 0
|
||||
for i, seg in enumerate(segments):
|
||||
seg_text = seg.text.strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
|
||||
# Сколько строк текста привязать к этому сегменту
|
||||
if i == len(whisper_parts) - 1:
|
||||
# Последний сегмент — все оставшиеся строки
|
||||
count = total_lines - line_idx
|
||||
else:
|
||||
# Пропорционально по символам
|
||||
ratio = len(seg_text) / len(full_whisper)
|
||||
count = max(1, round(total_lines * ratio))
|
||||
count = min(count, total_lines - line_idx)
|
||||
|
||||
for j in range(count):
|
||||
if line_idx >= total_lines:
|
||||
break
|
||||
t_start = seg.start + j * (seg.end - seg.start) / max(count, 1)
|
||||
t_end = seg.start + (j + 1) * (seg.end - seg.start) / max(count, 1)
|
||||
results.append({
|
||||
"start": round(t_start, 2),
|
||||
"end": round(t_end, 2),
|
||||
"text": txt_lines[line_idx],
|
||||
})
|
||||
line_idx += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Главная функция
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def transcribe(audio_path: str, text_path: Optional[str] = None,
|
||||
model_size: str = "base", device: str = "cpu",
|
||||
language: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
Универсальная функция транскрипции.
|
||||
|
||||
Параметры:
|
||||
audio_path — путь к аудиофайлу
|
||||
text_path — путь к .lrc/.srt/.txt (опционально)
|
||||
model_size — размер Whisper-модели (tiny, base, small, medium, large)
|
||||
device — "cpu" или "cuda"
|
||||
language — код языка (напр. "ru", "en") или None для авто
|
||||
|
||||
Возвращает: [{start, end, text}, ...]
|
||||
"""
|
||||
if text_path:
|
||||
ext = Path(text_path).suffix.lower()
|
||||
if ext == ".lrc":
|
||||
return parse_lrc(text_path)
|
||||
elif ext == ".srt":
|
||||
return parse_srt(text_path)
|
||||
elif ext == ".txt":
|
||||
return align_txt(audio_path, text_path, model_size, device)
|
||||
else:
|
||||
print(f"[transcribe] Неподдерживаемый формат: {text_path}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"[transcribe] Запускаем Whisper ({model_size}, {device})…")
|
||||
return transcribe_whisper(audio_path, model_size, language, device)
|
||||
148
tasks/karaoke/video_bg.py
Normal file
148
tasks/karaoke/video_bg.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
video_bg.py — Pexels API → скачать видео-клип для фона
|
||||
|
||||
Если Pexels не доступен → генерируем чёрный видео-файл.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
|
||||
|
||||
FFMPEG = os.environ.get("FFMPEG_BIN", os.path.expanduser("~/bin/ffmpeg-7.0.2-amd64-static/ffmpeg"))
|
||||
FFPROBE = os.environ.get("FFPROBE_BIN", os.path.expanduser("~/bin/ffmpeg-7.0.2-amd64-static/ffprobe"))
|
||||
|
||||
|
||||
def search_pexels(query: str, per_page: int = 3) -> list[dict]:
|
||||
"""Поиск видео на Pexels. Возвращает список видео-объектов."""
|
||||
api_key = os.environ.get("PEXELS_API_KEY", "")
|
||||
if not api_key:
|
||||
print("[video_bg] PEXELS_API_KEY не задан, пропускаем поиск.")
|
||||
return []
|
||||
|
||||
url = "https://api.pexels.com/videos/search"
|
||||
params = {
|
||||
"query": query,
|
||||
"per_page": per_page,
|
||||
"orientation": "landscape",
|
||||
"size": "medium",
|
||||
}
|
||||
headers = {"Authorization": api_key}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
print(f"[video_bg] Ошибка Pexels API: {e}")
|
||||
return []
|
||||
|
||||
return data.get("videos", [])
|
||||
|
||||
|
||||
def pick_video(video_list: list[dict]) -> str | None:
|
||||
"""Выбрать URL лучшего видео (HD 1280x720)."""
|
||||
for v in video_list:
|
||||
files = v.get("video_files", [])
|
||||
for f in files:
|
||||
# Предпочитаем 720p landscape
|
||||
width = f.get("width", 0)
|
||||
if width == 1280:
|
||||
return f.get("file")
|
||||
# Если нет 720p — берём первый ≥1280
|
||||
for f in sorted(files, key=lambda x: x.get("width", 0), reverse=True):
|
||||
if f.get("width", 0) >= 1280:
|
||||
return f.get("file")
|
||||
# Если вообще ничего ≥1280 — первый попавшийся
|
||||
if video_list and video_list[0].get("video_files"):
|
||||
return video_list[0]["video_files"][0].get("file")
|
||||
return None
|
||||
|
||||
|
||||
def download_url(url: str, dest: str) -> str:
|
||||
"""Скачать файл по URL в dest."""
|
||||
print(f"[video_bg] Скачиваем {url[:80]}…")
|
||||
resp = requests.get(url, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return dest
|
||||
|
||||
|
||||
def get_duration(path: str) -> float:
|
||||
"""Длительность видео в секундах через ffprobe."""
|
||||
cmd = [
|
||||
FFPROBE, "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", path
|
||||
]
|
||||
out = subprocess.check_output(cmd, text=True).strip()
|
||||
return float(out)
|
||||
|
||||
|
||||
def create_black_video(duration: float, dest: str, width: int = 1280, height: int = 720):
|
||||
"""Создать чёрное видео заданной длительности."""
|
||||
print(f"[video_bg] Создаём чёрный фон {duration:.1f}s…")
|
||||
cmd = [
|
||||
FFMPEG, "-f", "lavfi", "-i", f"color=c=black:s={width}x{height}:r=30:d={duration}",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", dest
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
return dest
|
||||
|
||||
|
||||
def loop_video(source: str, target_duration: float, dest: str):
|
||||
"""Зациклить видео до нужной длительности."""
|
||||
src_dur = get_duration(source)
|
||||
if src_dur >= target_duration:
|
||||
# Обрезать лишнее
|
||||
cmd = [
|
||||
FFMPEG, "-i", source, "-t", str(target_duration),
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", "-y", dest
|
||||
]
|
||||
else:
|
||||
# Зациклить через stream_loop
|
||||
loops = int(target_duration / src_dur) + 1
|
||||
cmd = [
|
||||
FFMPEG, "-stream_loop", str(loops - 1), "-i", source,
|
||||
"-t", str(target_duration),
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", "-y", dest
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
return dest
|
||||
|
||||
|
||||
def get_bg_video(search_query: str, audio_duration: float,
|
||||
output_dir: str) -> str:
|
||||
"""
|
||||
Основной entry point.
|
||||
|
||||
1. Ищет видео на Pexels по query
|
||||
2. Скачивает и зацикливает до audio_duration
|
||||
3. Если Pexels недоступен — создаёт чёрный фон
|
||||
|
||||
Возвращает путь к видео-файлу.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
raw_path = os.path.join(output_dir, "raw_bg.mp4")
|
||||
bg_path = os.path.join(output_dir, "background.mp4")
|
||||
|
||||
videos = search_pexels(search_query)
|
||||
url = pick_video(videos)
|
||||
|
||||
if url:
|
||||
try:
|
||||
download_url(url, raw_path)
|
||||
loop_video(raw_path, audio_duration, bg_path)
|
||||
print(f"[video_bg] Видео-фон готов: {bg_path}")
|
||||
return bg_path
|
||||
except Exception as e:
|
||||
print(f"[video_bg] Ошибка скачивания: {e}")
|
||||
|
||||
# Fallback: чёрный фон
|
||||
create_black_video(audio_duration, bg_path)
|
||||
return bg_path
|
||||
Reference in New Issue
Block a user