auto-sync: 2026-04-30 00:30:01

This commit is contained in:
Stream
2026-04-30 00:30:01 +03:00
parent cac2ebe19a
commit 6b07bd4597
7 changed files with 690 additions and 0 deletions

View File

@@ -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
View 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/`

View 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
View 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}

View 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
View 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
View 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