auto-sync: 2026-04-30 01:30:01

This commit is contained in:
Stream
2026-04-30 01:30:01 +03:00
parent 75ae562ab4
commit 91b2502ee1
3 changed files with 252 additions and 18 deletions

View File

@@ -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:

View File

@@ -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.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 ----------
@@ -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

View File

@@ -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
# ---------------------------------------------------------------------------
# Главная функция
# ---------------------------------------------------------------------------