458 lines
18 KiB
Python
458 lines
18 KiB
Python
"""
|
||
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,
|
||
alpha: int = None):
|
||
"""Рисует текст по центру кадра, с полупрозрачной тенью и цветом.
|
||
|
||
Работает корректно как с RGB, так и с RGBA-изображениями.
|
||
Если `alpha` передан — используется как общий множитель прозрачности.
|
||
"""
|
||
# Определяем цвет с учётом alpha
|
||
if active:
|
||
base_color = (255, 220, 0) # жёлтый
|
||
else:
|
||
base_color = (255, 255, 255) # белый
|
||
|
||
# Глобальный alpha (по умолчанию = fade_alpha)
|
||
g_alpha = alpha if alpha is not None else fade_alpha
|
||
|
||
# Цвет текста (RGBA)
|
||
txt_color = (base_color[0], base_color[1], base_color[2], g_alpha)
|
||
# Цвет тени — чёрный с альфа ~180, масштабируется по g_alpha
|
||
sh_alpha = int(180 * g_alpha / 255)
|
||
sh_color = (0, 0, 0, sh_alpha)
|
||
|
||
font = font_active if active else font_inactive
|
||
|
||
bbox = font.getbbox(text)
|
||
tw = bbox[2] - bbox[0]
|
||
th = bbox[3] - bbox[1]
|
||
x = (WIDTH - tw) // 2
|
||
y = int(HEIGHT * y_ratio) - th // 2
|
||
|
||
# Отрисовка на overlay с правильным alpha-композитингом
|
||
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(overlay)
|
||
# Тень
|
||
d.text((x + 3, y + 3), text, font=font, fill=sh_color)
|
||
# Основной текст
|
||
d.text((x, y), text, font=font, fill=txt_color)
|
||
|
||
# Композитим поверх переданного image
|
||
if image.mode == "RGBA":
|
||
image.alpha_composite(overlay)
|
||
else:
|
||
image.paste(overlay.convert("RGB"), (0, 0), overlay)
|
||
|
||
|
||
def draw_karaoke_line(frame: Image.Image, text: str, words: list,
|
||
t: float, font, y_ratio: float = 0.82):
|
||
"""Рисует karaoke-строку: каждое слово своим цветом по таймингам.
|
||
|
||
Слова до текущего момента → жёлтые (255, 220, 0)
|
||
Текущее слово → жёлтое + glow (чуть крупнее, ярче)
|
||
Слова после → белые (255, 255, 255)
|
||
"""
|
||
if not words:
|
||
# Fallback: нет word-timestamps — рисуем всю строку жёлтым
|
||
_draw_text_centered(frame, text, font, font, True, 255, y_ratio=y_ratio, alpha=255)
|
||
return
|
||
|
||
draw = ImageDraw.Draw(frame)
|
||
|
||
# Измеряем общую ширину
|
||
total_width = sum(draw.textlength(w["word"] + " ", font=font) for w in words)
|
||
x = (WIDTH - total_width) // 2
|
||
y = int(HEIGHT * y_ratio)
|
||
|
||
COLOR_DONE = (255, 220, 0, 255) # уже пропето — жёлтый
|
||
COLOR_WHITE = (255, 255, 255, 200) # ещё не пропето — белый
|
||
COLOR_SHADOW = (0, 0, 0, 180)
|
||
|
||
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
||
od = ImageDraw.Draw(overlay)
|
||
|
||
# Глоу-шрифт (чуть крупнее для текущего слова)
|
||
try:
|
||
glow_font = ImageFont.truetype(FONT_ACTIVE, FONT_SIZE + 4)
|
||
except Exception:
|
||
glow_font = font
|
||
|
||
for w in words:
|
||
word_text = w["word"] + " "
|
||
ww = draw.textlength(word_text, font=font)
|
||
|
||
if t >= w["end"]:
|
||
color = COLOR_DONE
|
||
use_font = font
|
||
elif t >= w["start"]:
|
||
# Текущее слово — жёлтый + glow
|
||
color = (255, 235, 50, 255) # чуть ярче
|
||
use_font = glow_font
|
||
# Glow: рисуем ещё раз с небольшим сдвигом и полупрозрачностью
|
||
od.text((x - 1, y - 1), word_text, font=glow_font, fill=(255, 220, 0, 80))
|
||
od.text((x + 1, y + 1), word_text, font=glow_font, fill=(255, 220, 0, 80))
|
||
else:
|
||
color = COLOR_WHITE
|
||
use_font = font
|
||
|
||
# Тень
|
||
od.text((x + 2, y + 2), word_text, font=use_font, fill=COLOR_SHADOW)
|
||
# Основной текст
|
||
od.text((x, y), word_text, font=use_font, fill=color)
|
||
x = int(x + ww)
|
||
|
||
if frame.mode == "RGBA":
|
||
frame.alpha_composite(overlay)
|
||
else:
|
||
frame.paste(overlay.convert("RGB"), (0, 0), overlay)
|
||
|
||
|
||
def draw_text_with_alpha(image: Image.Image, text: str,
|
||
font_active, font_inactive,
|
||
alpha: int = 255, active: bool = True,
|
||
y_ratio: float = 0.82):
|
||
"""Удобная обёртка: нарисовать одну строку с заданной прозрачностью."""
|
||
_draw_text_centered(image, text, font_active, font_inactive,
|
||
active=active, fade_alpha=alpha,
|
||
y_ratio=y_ratio, alpha=alpha)
|
||
|
||
|
||
def draw_progress_bar(image: Image.Image, current_time: float,
|
||
prev_end: float, next_start: float):
|
||
"""Рисует прогресс-бар для инструментальных пауз > 20 сек.
|
||
|
||
- Горизонтальная полоса по центру экрана (50% высоты)
|
||
- Ширина 60% кадра, высота 8px, скруглённые углы (r=4)
|
||
- Под полосой — таймер оставшегося времени
|
||
"""
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
bar_w = int(WIDTH * 0.60)
|
||
bar_h = 8
|
||
bar_x = (WIDTH - bar_w) // 2
|
||
bar_y = HEIGHT // 2
|
||
|
||
# Прогресс (0.0–1.0)
|
||
total_gap = next_start - prev_end
|
||
progress = max(0.0, min(1.0, (current_time - prev_end) / total_gap))
|
||
fill_w = int(bar_w * progress)
|
||
|
||
radius = 4
|
||
|
||
def _rounded_rect(draw_, x, y, w, h, r, fill):
|
||
"""Рисует прямоугольник со скруглёнными углами на RGBA overlay."""
|
||
ov = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(ov)
|
||
d.rounded_rectangle([x, y, x + w, y + h], radius=r, fill=fill)
|
||
if image.mode == "RGBA":
|
||
image.alpha_composite(ov)
|
||
else:
|
||
image.paste(ov.convert("RGB"), (0, 0), ov)
|
||
|
||
# Фон полосы
|
||
_rounded_rect(draw, bar_x, bar_y, bar_w, bar_h, radius, (80, 80, 80, 200))
|
||
# Заполнение
|
||
if fill_w > 0:
|
||
fill_r = min(radius, fill_w // 2, radius)
|
||
_rounded_rect(draw, bar_x, bar_y, fill_w, bar_h, fill_r, (255, 255, 255, 220))
|
||
|
||
# Таймер: оставшееся время до следующей строки
|
||
remaining = max(0, next_start - current_time)
|
||
mins = int(remaining) // 60
|
||
secs = int(remaining) % 60
|
||
timer_text = f"{mins}:{secs:02d}"
|
||
|
||
# Рисуем таймер под полосой (шрифт 32px)
|
||
try:
|
||
timer_font = ImageFont.truetype(FONT_INACTIVE, 32)
|
||
except Exception:
|
||
timer_font = ImageFont.load_default()
|
||
|
||
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(overlay)
|
||
bbox = d.textbbox((0, 0), timer_text, font=timer_font)
|
||
tw = bbox[2] - bbox[0]
|
||
tx = (WIDTH - tw) // 2
|
||
ty = bar_y + bar_h + 8
|
||
d.text((tx + 2, ty + 2), timer_text, font=timer_font, fill=(0, 0, 0, 140))
|
||
d.text((tx, ty), timer_text, font=timer_font, fill=(255, 255, 255, 220))
|
||
if image.mode == "RGBA":
|
||
image.alpha_composite(overlay)
|
||
else:
|
||
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
|
||
seg_idx = -1
|
||
for idx, seg in enumerate(segments):
|
||
if seg["start"] <= t <= seg["end"]:
|
||
active_seg = seg
|
||
seg_idx = idx
|
||
break
|
||
|
||
# Предыдущий сегмент (для прогресс-бара и fallback-текста)
|
||
prev_seg = None
|
||
prev_seg_end = 0.0
|
||
for seg in segments:
|
||
if seg["end"] <= t:
|
||
prev_seg = seg
|
||
prev_seg_end = seg["end"]
|
||
else:
|
||
break
|
||
|
||
# Следующий сегмент
|
||
next_seg = None
|
||
for seg in segments:
|
||
if seg["start"] > t:
|
||
next_seg = seg
|
||
break
|
||
|
||
gap = (next_seg["start"] - t) if next_seg else 999
|
||
|
||
if active_seg:
|
||
# Активный сегмент — karaoke-эффект если есть word-timestamps
|
||
has_words = isinstance(active_seg.get("words"), list) and len(active_seg.get("words", [])) > 0
|
||
if has_words:
|
||
draw_karaoke_line(frame, active_seg["text"], active_seg["words"], t, font_active)
|
||
else:
|
||
# Fallback без word-timestamps — старый fade in/out
|
||
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))
|
||
|
||
elif gap > 20 and next_seg:
|
||
# Длинная пауза (>20s) — показываем прогресс-бар
|
||
draw_progress_bar(frame, t, prev_seg_end, next_seg["start"])
|
||
|
||
elif gap <= 5 and next_seg:
|
||
# За 5 сек до следующей строки — плавное появление (fade in)
|
||
fade = 1.0 - (gap / 5.0) # 0.0 → 1.0
|
||
draw_text_with_alpha(frame, next_seg["text"],
|
||
font_active, font_inactive,
|
||
alpha=int(fade * 255), active=True)
|
||
|
||
else:
|
||
# Показываем предыдущий сегмент с пониженной заметностью
|
||
if prev_seg:
|
||
_draw_text_centered(frame, prev_seg["text"],
|
||
font_active, font_inactive,
|
||
False, 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
|