Files
wiki/tasks/karaoke/render.py
2026-04-30 02:10:01 +03:00

458 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.01.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