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

This commit is contained in:
Stream
2026-04-30 02:10:01 +03:00
parent 91b2502ee1
commit 4cfd909cc5
3 changed files with 194 additions and 14 deletions

View File

@@ -71,6 +71,8 @@ def main():
help="Устройство для Whisper (по умолчанию: cpu)")
parser.add_argument("--language", default=None,
help="Код языка (напр. ru, en) для Whisper (None = авто)")
parser.add_argument("--no-api", action="store_true",
help="Не использовать OpenAI API, только локальный faster-whisper")
args = parser.parse_args()
@@ -102,10 +104,12 @@ def main():
print(f" Используем готовый текст: {args.text} ({ext})")
segments = transcribe(audio_path, text_path=args.text,
model_size=model_size,
device=args.device, language=args.language)
device=args.device, language=args.language,
use_api=not args.no_api)
else:
segments = transcribe(audio_path, model_size=model_size,
device=args.device, language=args.language)
device=args.device, language=args.language,
use_api=not args.no_api)
if not segments:
print("Не удалось получить сегменты текста!")

View File

@@ -82,6 +82,69 @@ def _draw_text_centered(image: Image.Image, text: str,
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,
@@ -308,15 +371,20 @@ def render_with_bg(segments: list[dict], audio_path: str, bg_video: str,
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)
fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1)))
# Активный сегмент — 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))
_draw_text_centered(frame, active_seg["text"],
font_active, font_inactive,
True, max(fade_alpha, 128))
elif gap > 20 and next_seg:
# Длинная пауза (>20s) — показываем прогресс-бар

View File

@@ -14,11 +14,95 @@ import json
import subprocess
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
# ---------------------------------------------------------------------------
# Whisper транскрипция
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# OpenAI Whisper API (облако)
# ---------------------------------------------------------------------------
def transcribe_openai_api(audio_path: str, language: str = "ru") -> list[dict]:
"""Транскрипция через OpenAI Whisper API. Возвращает сегменты с word-timestamps."""
import httpx
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY не задан в окружении / ~/.openclaw/.env")
print("[transcribe] Отправляем аудио в OpenAI Whisper API…")
with open(audio_path, "rb") as f:
resp = httpx.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {api_key}"},
data={
"model": "whisper-1",
"response_format": "verbose_json",
"language": language,
"timestamp_granularities[]": ["word", "segment"],
},
files={"file": f},
timeout=300,
)
resp.raise_for_status()
data = resp.json()
# Собираем сегменты с word-timestamps
results = []
word_list = data.get("words", []) # плоский список слов с таймингами
segments = data.get("segments", [])
if word_list and segments:
# Распределяем слова по сегментам
seg_offset = 0
for seg in segments:
seg_start = seg["start"]
seg_end = seg["end"]
seg_text = seg.get("text", "").strip()
if not seg_text:
continue
# Собираем слова, попадающие в этот сегмент
seg_words = []
for w in word_list:
ws = w["start"]
we = w["end"]
ww = w.get("word", "")
# Слово принадлежит сегменту, если его начало попадает в диапазон
if ws >= seg_start - 0.05 and ws <= seg_end + 0.05:
seg_words.append({
"word": ww,
"start": round(ws, 2),
"end": round(we, 2),
})
results.append({
"start": round(seg_start, 2),
"end": round(seg_end, 2),
"text": seg_text,
"words": seg_words,
})
elif segments:
for seg in segments:
seg_text = seg.get("text", "").strip()
if not seg_text:
continue
results.append({
"start": round(seg["start"], 2),
"end": round(seg["end"], 2),
"text": seg_text,
})
print(f"[transcribe] OpenAI API вернул {len(results)} сегментов")
return results
# ---------------------------------------------------------------------------
# Whisper транскрипция (локальная)
# ---------------------------------------------------------------------------
def transcribe_whisper(audio_path: str, model_size: str = "base",
language: Optional[str] = None,
device: str = "cpu") -> list[dict]:
@@ -39,11 +123,23 @@ def transcribe_whisper(audio_path: str, model_size: str = "base",
text = seg.text.strip()
if not text:
continue
results.append({
# Собираем word-timestamps если есть
words = []
if hasattr(seg, 'words') and seg.words:
for w in seg.words:
words.append({
"word": w.word,
"start": round(w.start, 2),
"end": round(w.end, 2),
})
entry = {
"start": round(seg.start, 2),
"end": round(seg.end, 2),
"text": text,
})
}
if words:
entry["words"] = words
results.append(entry)
return results
@@ -244,7 +340,8 @@ def _align_by_segments(txt_lines: list[dict], whisper_segs: list[dict]) -> list[
def transcribe(audio_path: str, text_path: Optional[str] = None,
model_size: str = "base", device: str = "cpu",
language: Optional[str] = None) -> list[dict]:
language: Optional[str] = None,
use_api: bool = True) -> list[dict]:
"""
Универсальная функция транскрипции.
@@ -254,8 +351,9 @@ def transcribe(audio_path: str, text_path: Optional[str] = None,
model_size — размер Whisper-модели (tiny, base, small, medium, large)
device — "cpu" или "cuda"
language — код языка (напр. "ru", "en") или None для авто
use_api — использовать OpenAI API (по умолч. True)
Возвращает: [{start, end, text}, ...]
Возвращает: [{start, end, text, words?}, ...]
"""
if text_path:
ext = Path(text_path).suffix.lower()
@@ -269,5 +367,15 @@ def transcribe(audio_path: str, text_path: Optional[str] = None,
print(f"[transcribe] Неподдерживаемый формат: {text_path}")
sys.exit(1)
else:
# Проверяем, можно ли использовать OpenAI API
if use_api:
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
api_key = os.environ.get("OPENAI_API_KEY")
if api_key:
try:
return transcribe_openai_api(audio_path, language or "ru")
except Exception as e:
print(f"[transcribe] OpenAI API не удался ({e}), fallback на faster-whisper")
print(f"[transcribe] Запускаем Whisper ({model_size}, {device})…")
return transcribe_whisper(audio_path, model_size, language, device)