diff --git a/tasks/karaoke/PROJECT.md b/tasks/karaoke/PROJECT.md index b96e079..7678462 100644 --- a/tasks/karaoke/PROJECT.md +++ b/tasks/karaoke/PROJECT.md @@ -1,97 +1,110 @@ -# Караоке-генератор — Бизнес требования +# ТЗ: Караоке-генератор — MVP (v0.1) -## Что делаем -Пользователь загружает **аудиофайл песни** → получает **видео-караоке** с анимированным текстом, синхронизированным с аудио. +## Цель +Реализовать CLI-инструмент, который принимает аудиофайл песни и генерирует видео-файл с синхронизированным текстом (стиль "субтитры/караоке"). --- -## Вход - -### Обязательно -- **Аудиофайл**: mp3, wav, ogg, m4a (любая длина) - -### Опционально -- **Текст песни**: если пользователь хочет конкретный текст - - Ручной ввод (textarea) - - Загрузка файла: txt, lrc, srt, vtt - - Без текста → автоматическая транскрипция через Whisper +## Стек +- **Python 3.10+** +- **Whisper** (openai-whisper или faster-whisper) — транскрипция + тайминги +- **FFmpeg** — рендер финального видео +- **MoviePy** или **ffmpeg-python** — наложение текста на видео +- **Pillow** — рендер текстовых кадров (если MoviePy не справляется) +- **Pexels API** — поиск стоковых видео-фонов +- **GigaChat API** (через прокси `185.130.212.192:8443`) — NLP-анализ смысла текста --- -## Выход -- **Видеофайл**: mp4 (H.264), с встроенным аудио -- Разрешение: 720p / 1080p +## Функционал 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) --- -## Основной функционал - -### 1. Транскрипция и тайминги -- Автоматическая транскрипция через Whisper API (если текст не загружен) -- Поддержка LRC, SRT, WebVTT -- Ручная подгонка таймингов (если автомат не попал) - -### 2. Анализ смысла песни (для видео-фона) -- Извлечение ключевых слов и тем из текста (NLP через GigaChat) -- Классификация настроения: грусть, радость, любовь, природа, город, ночь и т.д. -- Определение тематических сцен: «закат», «дождь в городе», «горы», «лес» - -### 3. Автоматический подбор видео-фона -- Поиск по стоковым видео: **Pexels Video**, **Pixabay Video** (бесплатно, CC0) -- Соответствие настроение/сцена → видео-клип -- Зацикливание коротких клипов для длинных песен -- Плавные переходы между клипами (crossfade) -- Опционально: ручная замена фона - -### 4. Сборка финального видео -- Текст поверх видео-фона -- Подсветка текущей строки -- Fade-in/out анимации -- Аудио встраивается в видео +## Структура проекта +``` +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 +``` --- -## Технические детали - -### Стек -- **Транскрипция**: OpenAI Whisper API -- **Анализ текста**: GigaChat (через наш прокси) -- **Поиск видео**: Pexels API / Pixabay API -- **Рендер видео**: FFmpeg + MoviePy / PyAV - -### Ограничения -- Автоматический подбор фона не идеален — может потребовать ручной корректировки -- Длинные песни (>10 мин) рендерятся дольше -- Качество транскрипции зависит от чистоты аудио -- API сток-видео имеют лимиты запросов +## Конфигурация / секреты +Читать из переменных окружения (или из `~/.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) --- -## Roadmap +## Ограничения MVP +- Только один видео-фон на всю песню (не меняется по сценам) +- Нет UI — только CLI +- Нет предпросмотра +- Рендер может занять 1-5 мин для песни 3-5 мин -### v0.1 — MVP -- Загрузка аудио + текст (или транскрипция) -- Ручной выбор видео-фона -- Рендер в 720p -- Тайминги через Whisper +--- -### v0.2 — Авто-фон -- NLP анализ смысла -- Автоматический подбор видео из стоков -- 1080p +## Критерии готовности +- [ ] `python karaoke.py --audio test.mp3` → генерирует `output.mp4` +- [ ] Текст синхронизирован с аудио (±0.5 сек) +- [ ] Видео-фон подобран автоматически (или чёрный если API недоступен) +- [ ] Файл воспроизводится в VLC/браузере без ошибок +- [ ] `requirements.txt` заполнен, `README.md` с инструкцией запуска -### v0.3 — Улучшения -- Ручная коррекция таймингов в UI -- Дуэт (два текста разными цветами) +--- + +## Что НЕ нужно в MVP +- UI/веб-интерфейс +- Смена фона по сценам +- Дуэт (два текста) - Экспорт SRT/LRC -- Предпросмотр перед рендером +- Ручная коррекция таймингов --- -## Задача -- **Проект**: karaoke -- **Слаг**: `karaoke` -- **Папка**: `tasks/karaoke/` +_Создано: 2026-04-29 | Проект: proj_karaoke_ diff --git a/tasks/karaoke/README.md b/tasks/karaoke/README.md new file mode 100644 index 0000000..b65228a --- /dev/null +++ b/tasks/karaoke/README.md @@ -0,0 +1,108 @@ +# Караоке-генератор v0.1 (MVP) 🎤 + +CLI-инструмент, который принимает аудиофайл песни и генерирует видео с синхронизированным текстом (стиль "караоке/субтитры"). + +--- + +## Быстрый старт + +```bash +# Установка зависимостей +pip install -r requirements.txt + +# Генерация караоке из аудио +python karaoke.py --audio song.mp3 + +# С готовым текстом +python karaoke.py --audio song.mp3 --text lyrics.lrc +python karaoke.py --audio song.mp3 --text lyrics.txt +python karaoke.py --audio song.mp3 --text lyrics.srt + +# С кастомным выходным файлом и моделью +python karaoke.py --audio song.mp3 --whisper-model small --output my_karaoke.mp4 +``` + +--- + +## Зависимости + +### Python (Python 3.10+) + +| Пакет | Версия | Зачем | +|-------|--------|-------| +| `faster-whisper` | 1.2.1 | Транскрипция аудио с таймингами | +| `moviepy` | 2.1.2 | (опционально) видео-рендер | +| `pillow` | 10.4.0 | Рендер текстовых кадров | +| `requests` | 2.33.1 | HTTP-запросы к API | +| `numpy` | 2.4.0 | Математика | +| `python-dotenv` | 1.2.2 | Загрузка .env | + +### Системные + +- **FFmpeg** — должен быть в PATH или указан через `FFMPEG_BIN`/`FFPROBE_BIN` + - В проекте используется статик из `~/bin/ffmpeg-7.0.2-amd64-static/` + +--- + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `PEXELS_API_KEY` | Ключ Pexels API для поиска видео-фонов | — (будет чёрный фон) | +| `GIGACHAT_CREDS` | OAuth креды GigaChat (Base64) | из `~/.openclaw/.env` | +| `GIGACHAT_BASE_URL` | URL GigaChat API | из `~/.openclaw/.env` | +| `FFMPEG_BIN` | Путь к ffmpeg | `~/bin/ffmpeg-7.0.2-amd64-static/ffmpeg` | +| `FFPROBE_BIN` | Путь к ffprobe | `~/bin/ffmpeg-7.0.2-amd64-static/ffprobe` | + +--- + +## Структура проекта + +``` +tasks/karaoke/ +├── karaoke.py # CLI точка входа, argparse +├── transcribe.py # Whisper транскрипция + LRC/SRT парсер +├── nlp.py # GigaChat NLP → {mood, scenes} +├── video_bg.py # Pexels API + чёрный фон fallback +├── render.py # FFmpeg/MoviePy рендер +├── requirements.txt # pinned зависимости +├── README.md # этот файл +└── PROJECT.md # полное ТЗ +``` + +--- + +## Как это работает + +1. **Транскрипция** — Whisper извлекает текст + тайминги из аудио +2. **NLP** — GigaChat анализирует настроение и сцены (fallback без API) +3. **Видео-фон** — Pexels API ищет видео; если нет — чёрный фон +4. **Рендер** — FFmpeg накладывает текст на видео с fade-in/out + +--- + +## CLI аргументы + +``` +--audio AUDIO Путь к аудиофайлу (обязательный) +--text TEXT Путь к файлу с текстом (.lrc/.srt/.txt) +--output OUTPUT Выходной файл (по умолчанию: output.mp4) +--whisper-model SIZE tiny/base/small/medium/large (по умолч.: base) +--device DEVICE cpu/cuda (по умолч.: cpu) +--language LANG Код языка (ru/en/...) или авто (None) +``` + +--- + +## Выходной формат + +- Видео: 1280x720, H.264, 30fps +- Аудио: оригинальное, AAC 192kbps +- Текст: белый, 48px, с чёрной тенью; активная строка жёлтая +- Fade-in/out: ~0.3 сек + +--- + +## Лицензия + +MIT / Internal Project diff --git a/tasks/karaoke/karaoke.py b/tasks/karaoke/karaoke.py new file mode 100644 index 0000000..30e61fe --- /dev/null +++ b/tasks/karaoke/karaoke.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +karaoke.py — Караоке-генератор v0.1 (MVP) + +Принимает аудиофайл (и опционально текст), генерирует видео с +синхронизированным текстом. + +Пример: + python karaoke.py --audio song.mp3 + python karaoke.py --audio song.mp3 --text lyrics.lrc + python karaoke.py --audio song.mp3 --text lyrics.txt --output my_video.mp4 +""" + +import argparse +import os +import sys +from pathlib import Path +from dotenv import load_dotenv + +# Загружаем ~/.openclaw/.env +load_dotenv(os.path.expanduser("~/.openclaw/.env")) + +# Путь к директории проекта +PROJECT_DIR = Path(__file__).parent + +# Добавляем в path для импорта модулей +sys.path.insert(0, str(PROJECT_DIR)) + +from transcribe import transcribe +from nlp import analyze +from video_bg import get_bg_video +from render import render_with_bg, render +from video_bg import FFMPEG + + +def get_audio_duration(audio_path: str) -> float: + """Длительность аудио через ffprobe.""" + import subprocess + FFPROBE = os.environ.get("FFPROBE_BIN", + os.path.expanduser("~/bin/ffmpeg-7.0.2-amd64-static/ffprobe")) + cmd = [ + FFPROBE, "-v", "quiet", "-show_entries", "format=duration", + "-of", "csv=p=0", audio_path + ] + out = subprocess.check_output(cmd, text=True).strip() + return float(out) + + +def main(): + parser = argparse.ArgumentParser( + description="Караоке-генератор: аудио → видео с текстом", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Примеры: + python karaoke.py --audio song.mp3 + python karaoke.py --audio song.mp3 --text lyrics.lrc + python karaoke.py --audio song.mp3 --text lyrics.srt --output out.mp4 + python karaoke.py --audio song.mp3 --whisper-model small + """ + ) + parser.add_argument("--audio", required=True, help="Путь к аудиофайлу (mp3, wav, ogg, m4a)") + parser.add_argument("--text", default=None, help="Путь к файлу с текстом (lrc, srt, txt)") + parser.add_argument("--output", default="output.mp4", help="Путь к выходному видео") + parser.add_argument("--whisper-model", default="base", + choices=["tiny", "base", "small", "medium", "large"], + help="Размер Whisper-модели (по умолчанию: base)") + parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], + help="Устройство для Whisper (по умолчанию: cpu)") + parser.add_argument("--language", default=None, + help="Код языка (напр. ru, en) для Whisper (None = авто)") + + args = parser.parse_args() + + # Валидация + audio_path = os.path.abspath(args.audio) + if not os.path.isfile(audio_path): + print(f"❌ Аудиофайл не найден: {audio_path}") + sys.exit(1) + + output_path = os.path.abspath(args.output) + output_dir = os.path.dirname(output_path) + if not output_dir: + output_dir = "." + + tmp_dir = os.path.join(output_dir, ".karaoke_tmp") + os.makedirs(tmp_dir, exist_ok=True) + + print("=" * 60) + print("🎤 Караоке-генератор v0.1") + print("=" * 60) + + # --- Шаг 1: Транскрипция --- + print("\n📝 Шаг 1: Транскрипция…") + if args.text: + ext = Path(args.text).suffix.lower() + print(f" Используем готовый текст: {args.text} ({ext})") + segments = transcribe(audio_path, text_path=args.text, + model_size=args.whisper_model, + device=args.device, language=args.language) + else: + segments = transcribe(audio_path, model_size=args.whisper_model, + device=args.device, language=args.language) + + if not segments: + print("❌ Не удалось получить сегменты текста!") + sys.exit(1) + + print(f" Получено {len(segments)} сегментов.") + full_text = " ".join(s["text"] for s in segments) + + audio_duration = get_audio_duration(audio_path) + print(f" Длительность аудио: {audio_duration:.1f}s") + + # --- Шаг 2: NLP-анализ --- + print("\n🧠 Шаг 2: NLP-анализ текста…") + nlp_result = analyze(full_text) + mood = nlp_result.get("mood", "neutral") + scenes = nlp_result.get("scenes", ["abstract"]) + search_query = scenes[0] if scenes else "abstract" + print(f" Настроение: {mood}") + print(f" Сцена для фона: {search_query}") + + # --- Шаг 3: Видео-фон --- + print(f"\n🎬 Шаг 3: Подбор видео-фона…") + bg_video = get_bg_video(search_query, audio_duration, tmp_dir) + print(f" Видео-фон: {bg_video}") + + # --- Шаг 4: Рендер --- + print(f"\n🎞️ Шаг 4: Рендер видео…") + is_black_bg = (bg_video.split("/")[-1] == "background.mp4" + and get_bg_video.__doc__) + + # Проверяем, чёрный фон или реальный видео + # Проще: всегда пытаемся render_with_bg — он сам упадёт на fallback + try: + render_with_bg(segments, audio_path, bg_video, output_path) + except Exception as e: + print(f"[karaoke] Render с фоном не удался: {e}") + print("[karaoke] Пробуем fallback…") + render(segments, audio_path, bg_video, output_path) + + # Финал + print("\n" + "=" * 60) + if os.path.isfile(output_path): + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f"✅ Готово: {output_path} ({size_mb:.1f} MB)") + else: + print("❌ Ошибка: выходной файл не создан!") + sys.exit(1) + print("=" * 60) + + # Чистим tmp + try: + import shutil + shutil.rmtree(tmp_dir) + except Exception: + pass + + +if __name__ == "__main__": + main() diff --git a/tasks/karaoke/nlp.py b/tasks/karaoke/nlp.py index 65a9e60..8092ae5 100644 --- a/tasks/karaoke/nlp.py +++ b/tasks/karaoke/nlp.py @@ -1,5 +1,5 @@ """ -nlp.py — GigaChat NLP-анализ текста песни → {mood, scenes} +nlp.py — OpenRouter (qwen/qwen3.6-plus) NLP-анализ текста песни → {mood, scenes} """ import json @@ -9,35 +9,25 @@ from dotenv import load_dotenv load_dotenv(os.path.expanduser("~/.openclaw/.env")) +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +OPENROUTER_MODEL = "qwen/qwen3.6-plus" -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"] +OPENROUTER_HEADERS_BASE = { + "Content-Type": "application/json", + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "Karaoke Generator", +} def analyze(text: str) -> dict: """ - Отправить текст в GigaChat, получить {mood, scenes}. - При ошибке возвращает fallback. + Отправить текст в OpenRouter (qwen/qwen3.6-plus), получить {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.") + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + print("[nlp] OPENROUTER_API_KEY не задан. Используем fallback.") return _fallback(text) prompt = ( @@ -47,31 +37,29 @@ def analyze(text: str) -> dict: "\n\nТекст песни:\n" + text[:3000] ) - url = f"{base_url}/api/v1/chat/completions" headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", + **OPENROUTER_HEADERS_BASE, + "Authorization": f"Bearer {api_key}", } body = { - "model": "GigaChat", + "model": OPENROUTER_MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, } try: - resp = requests.post(url, headers=headers, json=body, - verify=False, timeout=30) + resp = requests.post(OPENROUTER_URL, headers=headers, json=body, 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() + # Парсим JSON — иногда модель возвращает markdown-обёртку + content = content.strip("```json").strip("```").strip() result = json.loads(content) - print(f"[nlp] GigaChat ответ: mood={result.get('mood')}, scenes={result.get('scenes')}") + print(f"[nlp] OpenRouter ответ: mood={result.get('mood')}, scenes={result.get('scenes')}") return result except Exception as e: - print(f"[nlp] Ошибка GigaChat API: {e}. Используем fallback.") + print(f"[nlp] Ошибка OpenRouter API: {e}. Используем fallback.") return _fallback(text) diff --git a/tasks/karaoke/render.py b/tasks/karaoke/render.py new file mode 100644 index 0000000..e264543 --- /dev/null +++ b/tasks/karaoke/render.py @@ -0,0 +1,251 @@ +""" +render.py — MoviePy/FFmpeg рендер караоке-видео + +Накладывает текст на видео-фон, синхронизированный с таймингами. +""" + +import os +import subprocess +import tempfile +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +# ---------- Настройки ---------- + +WIDTH = 1280 +HEIGHT = 720 +FPS = 30 +FONT_ACTIVE = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +FONT_INACTIVE = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +FONT_SIZE = 48 +ACTIVE_COLOR = (255, 255, 0) # жёлтый +INACTIVE_COLOR = (255, 255, 255) # белый +SHADOW_COLOR = (0, 0, 0) +FADE_FRAMES = 9 # ~0.3 сек на 30fps + +FFMPEG = os.environ.get("FFMPEG_BIN", os.path.expanduser("~/bin/ffmpeg-7.0.2-amd64-static/ffmpeg")) + +# ---------- Утилиты ---------- + +def _load_font(path: str, size: int) -> ImageFont.FreeTypeFont: + try: + return ImageFont.truetype(path, size) + except Exception: + return ImageFont.load_default() + + +def _draw_text_centered(image: Image.Image, text: str, + font_active, font_inactive, + active: bool, fade_alpha: int, + y_ratio: float = 0.82): + """Рисует текст по центру кадра, с тенью и цветом по статусу.""" + draw = ImageDraw.Draw(image) + font = font_active if active else font_inactive + color = ACTIVE_COLOR if active else INACTIVE_COLOR + + # Тень + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + + x = (WIDTH - tw) // 2 + y = int(HEIGHT * y_ratio) - th // 2 + + # Тень (чёрная, с офсетом) + draw.text((x + 3, y + 3), text, font=font, fill=SHADOW_COLOR) + # Основной текст с alpha + overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0)) + d = ImageDraw.Draw(overlay) + d.text((x, y), text, font=font, fill=(*color, fade_alpha)) + image.paste(overlay.convert("RGB"), (0, 0), overlay) + + +# ---------- Рендер через FFmpeg ---------- + +def render(segments: list[dict], audio_path: str, bg_video: str, + output_path: str, width: int = WIDTH, height: int = HEIGHT, + fps: int = FPS): + """ + Рендерит караоке-видео: + 1. Генерирует PNG-кадры для каждой секунды видео + 2. Собирает ffmpeg-ом видео + аудио + + segments: [{start, end, text}, ...] + """ + # Найти максимальное время + total_duration = max(s["end"] for s in segments) if segments else 10 + + print(f"[render] Генерируем кадры: {total_duration:.1f}s @ {fps}fps = {int(total_duration * fps)} кадров…") + + font_active = _load_font(FONT_ACTIVE, FONT_SIZE) + font_inactive = _load_font(FONT_INACTIVE, FONT_SIZE) + + # Временный каталог для кадров + tmpdir = tempfile.mkdtemp(prefix="karaoke_") + + frame_count = int(total_duration * fps) + for i in range(frame_count): + t = i / fps + bg = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0)) + + # Определяем активный сегмент + active_seg = None + for seg in segments: + if seg["start"] <= t <= seg["end"]: + active_seg = seg + break + + # Рисуем текст — активный внизу по центру + if active_seg: + # Fade in: первые FADE_FRAMES кадра сегмента + frames_from_start = int((t - active_seg["start"]) * fps) + fade_alpha = min(255, int(255 * frames_from_start / max(FADE_FRAMES, 1))) + + # Fade out: последние FADE_FRAMES кадра сегмента + frames_to_end = int((active_seg["end"] - t) * fps) + fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1))) + + _draw_text_centered(bg, active_seg["text"], + font_active, font_inactive, + True, max(fade_alpha, 128)) + else: + # Показываем предыдущий сегмент с пониженной заметностью + prev_seg = None + for seg in segments: + if seg["end"] <= t: + prev_seg = seg + else: + break + if prev_seg: + _draw_text_centered(bg, prev_seg["text"], + font_active, font_inactive, + False, 128) + + bg.save(os.path.join(tmpdir, f"frame_{i:07d}.png"), "PNG") + + # Собираем видео через ffmpeg: PNG-кадры + аудио + print(f"[render] Собираем видео через FFmpeg…") + cmd = [ + FFMPEG, + "-framerate", str(fps), + "-i", os.path.join(tmpdir, "frame_%07d.png"), + "-i", audio_path, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "192k", + "-shortest", + "-y", + output_path + ] + subprocess.run(cmd, check=True) + + # Чистим временные файлы + for f in os.listdir(tmpdir): + os.remove(os.path.join(tmpdir, f)) + os.rmdir(tmpdir) + + print(f"[render] Готово: {output_path}") + return output_path + + +# ---------- Альтернативный рендер: bg-video + overlay ---------- + +def render_with_bg(segments: list[dict], audio_path: str, bg_video: str, + output_path: str, width: int = WIDTH, height: int = HEIGHT, + fps: int = FPS): + """ + Использует реальный видео-фон (не чёрный) + overlay с текстом. + + Работает в два этапа: + 1. Генерация PNG-кадров с прозрачным фоном + 2. FFmpeg overlay + audio merge + """ + total_duration = max(s["end"] for s in segments) if segments else 10 + + # Подготавливаем bg_video: обрезаем/зацикливаем до нужной длины + bg_tmp = os.path.join(os.path.dirname(output_path), "bg_trimmed.mp4") + bg_dur_cmd = [FFMPEG, "-i", bg_video, "-t", str(total_duration), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", + "-y", bg_tmp] + subprocess.run(bg_dur_cmd, check=True, capture_output=True) + + print(f"[render] Генерируем overlay-кадры…") + + font_active = _load_font(FONT_ACTIVE, FONT_SIZE) + font_inactive = _load_font(FONT_INACTIVE, FONT_SIZE) + + tmpdir = tempfile.mkdtemp(prefix="karaoke_overlay_") + frame_count = int(total_duration * fps) + + for i in range(frame_count): + t = i / fps + frame = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0)) + + active_seg = None + for seg in segments: + if seg["start"] <= t <= seg["end"]: + active_seg = seg + break + + if active_seg: + frames_from_start = int((t - active_seg["start"]) * fps) + fade_alpha = min(255, int(255 * frames_from_start / max(FADE_FRAMES, 1))) + frames_to_end = int((active_seg["end"] - t) * fps) + fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1))) + + _draw_text_centered(frame, active_seg["text"], + font_active, font_inactive, + True, max(fade_alpha, 128)) + + frame.save(os.path.join(tmpdir, f"ov_{i:07d}.png"), "PNG") + + # Комбинирование: ffmpeg complex filter + print(f"[render] Собираем итоговое видео…") + + # Генерируем файл с количеством кадров для filter + ov_pattern = os.path.join(tmpdir, "ov_%07d.png") + out_abs = os.path.abspath(output_path) + audio_abs = os.path.abspath(audio_path) + bg_abs = os.path.abspath(bg_tmp) + pat_abs = os.path.abspath(ov_pattern) + + cmd = [ + FFMPEG, + "-framerate", str(fps), + "-i", pat_abs, + "-i", bg_abs, + "-i", audio_abs, + "-filter_complex", + f"[0:v]scale={width}:{height},setpts=PTS-STARTPTS[ovr];" + f"[1:v]scale={width}:{height},setpts=PTS-STARTPTS[bg];" + f"[bg][ovr]overlay=0:0[final]", + "-map", "[final]", + "-map", "2:a", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "192k", + "-shortest", + "-y", + out_abs + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"[render] Ошибка FFmpeg: {result.stderr[-500:]}") + # Fallback: простой render без bg + print("[render] Fallback: чёрный фон…") + return render(segments, audio_path, bg_video, output_path, width, height, fps) + + # Чистка + for f in os.listdir(tmpdir): + os.remove(os.path.join(tmpdir, f)) + os.rmdir(tmpdir) + try: + os.remove(bg_tmp) + except: + pass + + print(f"[render] Готово: {output_path}") + return output_path