From 91b2502ee10e1ad9e73c570fece7b6ce5fde27eb Mon Sep 17 00:00:00 2001 From: Stream Date: Thu, 30 Apr 2026 01:30:01 +0300 Subject: [PATCH] auto-sync: 2026-04-30 01:30:01 --- tasks/karaoke/karaoke.py | 10 ++- tasks/karaoke/render.py | 166 +++++++++++++++++++++++++++++++++--- tasks/karaoke/transcribe.py | 94 +++++++++++++++++++- 3 files changed, 252 insertions(+), 18 deletions(-) diff --git a/tasks/karaoke/karaoke.py b/tasks/karaoke/karaoke.py index 30e61fe..52c8ede 100644 --- a/tasks/karaoke/karaoke.py +++ b/tasks/karaoke/karaoke.py @@ -64,6 +64,9 @@ def main(): parser.add_argument("--whisper-model", default="base", choices=["tiny", "base", "small", "medium", "large"], help="Размер Whisper-модели (по умолчанию: base)") + parser.add_argument("--model", default=None, + choices=["tiny", "base", "small", "medium", "large"], + help="Алиас для --whisper-model (удобнее для .txt)") parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], help="Устройство для Whisper (по умолчанию: cpu)") parser.add_argument("--language", default=None, @@ -71,6 +74,9 @@ def main(): args = parser.parse_args() + # --model алиас для --whisper-model + model_size = args.model if args.model else args.whisper_model + # Валидация audio_path = os.path.abspath(args.audio) if not os.path.isfile(audio_path): @@ -95,10 +101,10 @@ def main(): ext = Path(args.text).suffix.lower() print(f" Используем готовый текст: {args.text} ({ext})") segments = transcribe(audio_path, text_path=args.text, - model_size=args.whisper_model, + model_size=model_size, device=args.device, language=args.language) else: - segments = transcribe(audio_path, model_size=args.whisper_model, + segments = transcribe(audio_path, model_size=model_size, device=args.device, language=args.language) if not segments: diff --git a/tasks/karaoke/render.py b/tasks/karaoke/render.py index e264543..2c44f3f 100644 --- a/tasks/karaoke/render.py +++ b/tasks/karaoke/render.py @@ -37,27 +37,124 @@ def _load_font(path: str, size: int) -> ImageFont.FreeTypeFont: 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 + y_ratio: float = 0.82, + alpha: int = None): + """Рисует текст по центру кадра, с полупрозрачной тенью и цветом. - # Тень - bbox = draw.textbbox((0, 0), text, font=font) + Работает корректно как с 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 - # Тень (чёрная, с офсетом) - draw.text((x + 3, y + 3), text, font=font, fill=SHADOW_COLOR) - # Основной текст с alpha + # Отрисовка на overlay с правильным 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) + # Тень + 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_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 ---------- @@ -182,13 +279,36 @@ def render_with_bg(segments: list[dict], audio_path: str, bg_video: str, t = i / fps frame = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0)) + # Определяем активный сегмент active_seg = None - for seg in segments: + 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: + # Активный сегмент — рисуем с 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) @@ -198,6 +318,24 @@ def render_with_bg(segments: list[dict], audio_path: str, bg_video: str, 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 diff --git a/tasks/karaoke/transcribe.py b/tasks/karaoke/transcribe.py index eded913..4c0f427 100644 --- a/tasks/karaoke/transcribe.py +++ b/tasks/karaoke/transcribe.py @@ -133,21 +133,111 @@ def _get_audio_duration(audio_path: str) -> float: def align_txt(audio_path: str, txt_path: str, model_size: str = "base", device: str = "cpu") -> list[dict]: - """Равномерно распределить строки .txt по длительности аудио.""" + """Выровнить строки .txt по Whisper-таймингам. + + Алгоритм: + 1. Запустить Whisper (language='ru', word_timestamps=True) + 2. Если получили >5 сегментов с реальным текстом (не 'Music','♪' и т.п.) + — выровнять строки текста по сегментам пропорционально + 3. Fallback: равномерное распределение по длительности аудио + """ txt_lines = Path(txt_path).read_text(encoding="utf-8").splitlines() txt_lines = [l.strip() for l in txt_lines if l.strip()] total_lines = len(txt_lines) if total_lines == 0: return [] + # Попробовать выровнить через Whisper + _SKIPPED = re.compile(r"^(music|\[music\]|instrumental|♪+|♫*|\(music\)|$)", re.IGNORECASE) + try: + whisper_segs = transcribe_whisper(audio_path, model_size, + language="ru", device=device) + + # Фильтруем «мусорные» сегменты + real_segs = [s for s in whisper_segs if not _SKIPPED.match(s["text"].strip())] + + if len(real_segs) > 5: + # Есть достаточно реальных сегментов — выравниваем строки по ним + return _align_by_segments(txt_lines, real_segs) + except Exception as e: + print(f"[transcribe] Whisper alignment не удался ({e}), fallback на равномерное распределение") + + # Fallback: равномерное распределение + return _align_uniform(txt_lines, audio_path) + + +def _align_uniform(txt_lines: list[str], audio_path: str) -> list[dict]: + """Равномерно распределить строки по длительности аудио.""" + total_lines = len(txt_lines) duration = _get_audio_duration(audio_path) slot = duration / total_lines - return [{"start": round(i * slot, 2), "end": round((i + 1) * slot, 2), "text": txt_lines[i]} for i in range(total_lines)] +def _align_by_segments(txt_lines: list[dict], whisper_segs: list[dict]) -> list[dict]: + """Выровнять строки текста по Whisper-сегментам. + + Каждый Whisper-сегмент может охватывать несколько строк текста. + Распределяем строки внутри диапазона сегмента пропорционально. + """ + if not txt_lines: + return [] + + # Если строк больше чем сегментов — распределяем строки по сегментам + num_segs = len(whisper_segs) + total_lines = len(txt_lines) + + if total_lines <= num_segs: + # Строк меньше или столько же сколько сегментов — берём сегменты напрямую + # Берём первые total_lines сегментов + result = [] + for i in range(total_lines): + seg = whisper_segs[i] if i < num_segs else whisper_segs[-1] + result.append({ + "start": seg["start"], + "end": seg["end"], + "text": txt_lines[i] + }) + return result + + # Строк больше чем сегментов — распределяем пропорционально + result = [] + seg_idx = 0 + lines_per_seg = max(1, total_lines // num_segs) + + for i, line in enumerate(txt_lines): + # Определяем к какому сегменту принадлежит строка + seg_idx = min(i // lines_per_seg, num_segs - 1) + + # Границы текущего сегмента + seg_start = whisper_segs[seg_idx]["start"] + seg_end = whisper_segs[min(seg_idx + 1, num_segs - 1)]["start"] if seg_idx + 1 < num_segs else whisper_segs[seg_idx]["end"] + + # Позиция внутри группы строк текущего сегмента + group_start = seg_idx * lines_per_seg + group_size = min(lines_per_seg, total_lines - group_start) + line_in_group = i - group_start + + # Пропорциональное распределение внутри сегмента + if group_size > 1: + line_dur = (seg_end - seg_start) / group_size + line_start = seg_start + line_in_group * line_dur + line_end = line_start + line_dur + else: + line_start = seg_start + line_end = seg_end + + result.append({ + "start": round(line_start, 2), + "end": round(line_end, 2), + "text": line + }) + + return result + + # --------------------------------------------------------------------------- # Главная функция # ---------------------------------------------------------------------------