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