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

This commit is contained in:
Stream
2026-04-30 00:40:01 +03:00
parent 6b07bd4597
commit 9da00de17b
5 changed files with 627 additions and 106 deletions

View File

@@ -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_

108
tasks/karaoke/README.md Normal file
View File

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

161
tasks/karaoke/karaoke.py Normal file
View File

@@ -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()

View File

@@ -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)

251
tasks/karaoke/render.py Normal file
View File

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