auto-sync: 2026-04-30 00:40:01
This commit is contained in:
@@ -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
108
tasks/karaoke/README.md
Normal 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
161
tasks/karaoke/karaoke.py
Normal 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()
|
||||
@@ -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
251
tasks/karaoke/render.py
Normal 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
|
||||
Reference in New Issue
Block a user