auto-sync: 2026-04-30 01:30:01
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Главная функция
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user