diff --git a/memory/ontology/graph.jsonl b/memory/ontology/graph.jsonl index 2adcbed..3c8b434 100644 --- a/memory/ontology/graph.jsonl +++ b/memory/ontology/graph.jsonl @@ -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"} diff --git a/tasks/karaoke/PROJECT.md b/tasks/karaoke/PROJECT.md new file mode 100644 index 0000000..b96e079 --- /dev/null +++ b/tasks/karaoke/PROJECT.md @@ -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/` diff --git a/tasks/karaoke/TASKS/active/project-setup/TASK.md b/tasks/karaoke/TASKS/active/project-setup/TASK.md new file mode 100644 index 0000000..7678462 --- /dev/null +++ b/tasks/karaoke/TASKS/active/project-setup/TASK.md @@ -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_ diff --git a/tasks/karaoke/nlp.py b/tasks/karaoke/nlp.py new file mode 100644 index 0000000..65a9e60 --- /dev/null +++ b/tasks/karaoke/nlp.py @@ -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} diff --git a/tasks/karaoke/requirements.txt b/tasks/karaoke/requirements.txt new file mode 100644 index 0000000..fadc7c2 --- /dev/null +++ b/tasks/karaoke/requirements.txt @@ -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 diff --git a/tasks/karaoke/transcribe.py b/tasks/karaoke/transcribe.py new file mode 100644 index 0000000..ea68ff7 --- /dev/null +++ b/tasks/karaoke/transcribe.py @@ -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) diff --git a/tasks/karaoke/video_bg.py b/tasks/karaoke/video_bg.py new file mode 100644 index 0000000..4ac8e9a --- /dev/null +++ b/tasks/karaoke/video_bg.py @@ -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