YoutubeScript / transcriber.py
Heebin Moon
Initial commit: YouTube transcript formatter
a2749f3
Raw History Blame
19.6 kB
"""YouTube ์˜์ƒ์—์„œ ์Œ์„ฑ์„ ์ถ”์ถœํ•˜๊ณ  ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ํ•ต์‹ฌ ๋ชจ๋“ˆ."""
from __future__ import annotations
import os
import re
import shutil
import tempfile
from typing import Callable, List, Optional
from pathlib import Path
from pytubefix import YouTube
from pydub import AudioSegment
def download_audio(
url: str,
output_dir: Optional[str] = None,
on_progress: Optional[Callable] = None,
) -> dict:
"""YouTube URL์—์„œ ์˜ค๋””์˜ค๋ฅผ ๋‹ค์šด๋กœ๋“œํ•œ๋‹ค."""
if output_dir is None:
output_dir = tempfile.mkdtemp()
def _notify(percent: int, detail: str):
if on_progress:
on_progress({"step": "download", "percent": percent, "detail": detail})
_notify(0, "์˜์ƒ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘...")
# pytubefix ๋‹ค์šด๋กœ๋“œ ์ง„ํ–‰ ์ฝœ๋ฐฑ
def _download_cb(stream, chunk, bytes_remaining):
total = stream.filesize
downloaded = total - bytes_remaining
pct = min(int(downloaded / total * 80), 80) # 0~80%: ๋‹ค์šด๋กœ๋“œ
dl_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
_notify(pct, f"๋‹ค์šด๋กœ๋“œ ์ค‘... {dl_mb:.1f}MB / {total_mb:.1f}MB")
yt = YouTube(url, on_progress_callback=_download_cb)
title = yt.title
duration = yt.length
_notify(5, f"'{title}' ์˜ค๋””์˜ค ์ŠคํŠธ๋ฆผ ์„ ํƒ ์ค‘...")
audio_stream = yt.streams.filter(only_audio=True).order_by("abr").desc().first()
if not audio_stream:
raise RuntimeError("์˜ค๋””์˜ค ์ŠคํŠธ๋ฆผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
# ๋‹ค์šด๋กœ๋“œ (m4a/webm)
downloaded = audio_stream.download(output_path=output_dir)
# mp3๋กœ ๋ณ€ํ™˜ (80~100%)
_notify(85, "MP3๋กœ ๋ณ€ํ™˜ ์ค‘...")
mp3_path = os.path.join(output_dir, Path(downloaded).stem + ".mp3")
audio = AudioSegment.from_file(downloaded)
audio.export(mp3_path, format="mp3", bitrate="192k")
if downloaded != mp3_path and os.path.exists(downloaded):
os.remove(downloaded)
file_mb = os.path.getsize(mp3_path) / (1024 * 1024)
_notify(100, f"๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ ({file_mb:.1f}MB, {duration}์ดˆ)")
return {"audio_path": mp3_path, "title": title, "duration": duration}
def download_video(
url: str,
output_dir: Optional[str] = None,
on_progress: Optional[Callable] = None,
max_resolution: int = 720,
) -> str:
"""ํ‚คํ”„๋ ˆ์ž„ ์ถ”์ถœ์„ ์œ„ํ•ด YouTube ์˜์ƒ์„ ๋‹ค์šด๋กœ๋“œํ•œ๋‹ค (720p ์ดํ•˜)."""
if output_dir is None:
output_dir = tempfile.mkdtemp()
def _notify(percent: int, detail: str):
if on_progress:
on_progress({"step": "download", "percent": percent, "detail": detail})
_notify(50, "ํ‚คํ”„๋ ˆ์ž„์šฉ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ ์ค‘...")
def _download_cb(stream, chunk, bytes_remaining):
total = stream.filesize
downloaded = total - bytes_remaining
pct = min(50 + int(downloaded / total * 45), 95)
dl_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
_notify(pct, f"์˜์ƒ ๋‹ค์šด๋กœ๋“œ ์ค‘... {dl_mb:.1f}MB / {total_mb:.1f}MB")
yt = YouTube(url, on_progress_callback=_download_cb)
# progressive ์ŠคํŠธ๋ฆผ (์˜ค๋””์˜ค+๋น„๋””์˜ค ํ•ฉ๋ณธ, ์ž‘์€ ํŒŒ์ผ)
stream = (
yt.streams.filter(progressive=True, file_extension="mp4")
.filter(res=f"{max_resolution}p")
.first()
)
if not stream:
# ํ•ด์ƒ๋„ ์ œํ•œ ์—†์ด ๊ฐ€์žฅ ๋‚ฎ์€ progressive ์‹œ๋„
stream = (
yt.streams.filter(progressive=True, file_extension="mp4")
.order_by("resolution")
.first()
)
if not stream:
raise RuntimeError("์˜์ƒ ์ŠคํŠธ๋ฆผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
video_path = stream.download(output_path=output_dir, filename_prefix="video_")
_notify(98, "์˜์ƒ ๋‹ค์šด๋กœ๋“œ ์™„๋ฃŒ")
return video_path
def extract_keyframes(
video_path: str,
output_dir: Optional[str] = None,
method: str = "scene",
interval_seconds: int = 30,
max_frames: int = 50,
on_progress: Optional[Callable] = None,
) -> List[dict]:
"""์˜์ƒ์—์„œ ํ‚คํ”„๋ ˆ์ž„์„ ์ถ”์ถœํ•œ๋‹ค (OpenCV).
method="scene": ํžˆ์Šคํ† ๊ทธ๋žจ ๋น„๊ต๋กœ ์žฅ๋ฉด ์ „ํ™˜ ๊ฐ์ง€.
method="interval": N์ดˆ ๊ฐ„๊ฒฉ์œผ๋กœ ์ถ”์ถœ.
๋ฐ˜ํ™˜: [{"path": str, "timestamp": "MM:SS"}, ...]
"""
import cv2
def _notify(percent: int, detail: str):
if on_progress:
on_progress({"step": "keyframe_extract", "percent": percent, "detail": detail})
if output_dir is None:
output_dir = tempfile.mkdtemp()
os.makedirs(output_dir, exist_ok=True)
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"์˜์ƒ ํŒŒ์ผ์„ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
total_seconds = total_frames / fps
_notify(5, f"์˜์ƒ ๋ถ„์„ ์‹œ์ž‘ ({total_seconds:.0f}์ดˆ, {fps:.0f}fps)")
keyframes = []
prev_hist = None
frame_idx = 0
threshold = 0.6 # ์žฅ๋ฉด ์ „ํ™˜ ๊ฐ์ง€ ์ž„๊ณ„๊ฐ’
while True:
ret, frame = cap.read()
if not ret:
break
current_seconds = frame_idx / fps
pct = min(int(5 + (frame_idx / total_frames) * 85), 90)
should_save = False
if method == "scene":
# HSV ํžˆ์Šคํ† ๊ทธ๋žจ ๋น„๊ต๋กœ ์žฅ๋ฉด ์ „ํ™˜ ๊ฐ์ง€
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
hist = cv2.calcHist([hsv], [0, 1], None, [50, 60], [0, 180, 0, 256])
cv2.normalize(hist, hist, 0, 1, cv2.NORM_MINMAX)
if prev_hist is not None:
score = cv2.compareHist(prev_hist, hist, cv2.HISTCMP_CORREL)
if score < threshold:
should_save = True
else:
# ์ฒซ ํ”„๋ ˆ์ž„์€ ํ•ญ์ƒ ์ €์žฅ
should_save = True
prev_hist = hist
else:
# interval ๋ชจ๋“œ: N์ดˆ ๊ฐ„๊ฒฉ
if frame_idx == 0 or (frame_idx % int(fps * interval_seconds) == 0):
should_save = True
if should_save and len(keyframes) < max_frames:
mins = int(current_seconds // 60)
secs = int(current_seconds % 60)
timestamp = f"{mins:02d}:{secs:02d}"
frame_path = os.path.join(output_dir, f"keyframe_{len(keyframes) + 1:03d}.jpg")
cv2.imwrite(frame_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
keyframes.append({"path": frame_path, "timestamp": timestamp})
if len(keyframes) % 5 == 0:
_notify(pct, f"{len(keyframes)}๊ฐœ ํ‚คํ”„๋ ˆ์ž„ ์ถ”์ถœ๋จ ({timestamp})")
if len(keyframes) >= max_frames:
break
frame_idx += 1
cap.release()
_notify(100, f"ํ‚คํ”„๋ ˆ์ž„ ์ถ”์ถœ ์™„๋ฃŒ: {len(keyframes)}๊ฐœ")
return keyframes
def _split_audio(audio_path: str, max_size_mb: int = 24) -> List[str]:
"""์˜ค๋””์˜ค ํŒŒ์ผ์„ Whisper API ์ œํ•œ(25MB)์— ๋งž๊ฒŒ ๋ถ„ํ• ํ•œ๋‹ค."""
file_size = os.path.getsize(audio_path)
max_bytes = max_size_mb * 1024 * 1024
if file_size <= max_bytes:
return [audio_path]
audio = AudioSegment.from_mp3(audio_path)
total_ms = len(audio)
num_chunks = (file_size // max_bytes) + 1
chunk_ms = total_ms // num_chunks
chunks = []
base = Path(audio_path)
for i in range(num_chunks):
start = i * chunk_ms
end = min((i + 1) * chunk_ms, total_ms)
chunk = audio[start:end]
chunk_path = str(base.parent / f"{base.stem}_part{i}{base.suffix}")
chunk.export(chunk_path, format="mp3")
chunks.append(chunk_path)
return chunks
def transcribe_api(
audio_path: str,
api_key: str,
on_progress: Optional[Callable] = None,
) -> dict:
"""OpenAI Whisper API๋กœ ์Œ์„ฑ์„ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
from openai import OpenAI
def _notify(percent: int, detail: str):
if on_progress:
on_progress({"step": "transcribe", "percent": percent, "detail": detail})
_notify(0, "์˜ค๋””์˜ค ํŒŒ์ผ ๋ถ„์„ ์ค‘...")
client = OpenAI(api_key=api_key)
chunks = _split_audio(audio_path)
total_chunks = len(chunks)
all_segments = []
full_text_parts = []
detected_language = None
time_offset = 0.0
if total_chunks > 1:
_notify(5, f"ํŒŒ์ผ์ด ์ปค์„œ {total_chunks}๊ฐœ๋กœ ๋ถ„ํ• ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค")
for i, chunk_path in enumerate(chunks):
chunk_pct_start = int(i / total_chunks * 90)
chunk_pct_end = int((i + 1) / total_chunks * 90)
if total_chunks > 1:
_notify(chunk_pct_start, f"์ฒญํฌ {i + 1}/{total_chunks} Whisper API ์ „์†ก ์ค‘...")
else:
_notify(10, "Whisper API๋กœ ์ „์†ก ์ค‘...")
with open(chunk_path, "rb") as f:
response = client.audio.transcriptions.create(
model="whisper-1",
file=f,
response_format="verbose_json",
timestamp_granularities=["segment"],
)
if detected_language is None:
detected_language = response.language
full_text_parts.append(response.text)
if total_chunks > 1:
_notify(chunk_pct_end, f"์ฒญํฌ {i + 1}/{total_chunks} ์™„๋ฃŒ")
else:
_notify(85, "์‘๋‹ต ์ฒ˜๋ฆฌ ์ค‘...")
if response.segments:
for seg in response.segments:
_get = (lambda k: seg[k]) if isinstance(seg, dict) else (lambda k: getattr(seg, k))
all_segments.append(
{
"start": _get("start") + time_offset,
"end": _get("end") + time_offset,
"text": _get("text"),
}
)
if chunks[-1] != chunk_path:
audio = AudioSegment.from_mp3(chunk_path)
time_offset += len(audio) / 1000.0
# ๋ถ„ํ• ๋œ ์ž„์‹œ ํŒŒ์ผ ์ •๋ฆฌ
for chunk_path in chunks:
if chunk_path != audio_path and os.path.exists(chunk_path):
os.remove(chunk_path)
_notify(100, f"๋ณ€ํ™˜ ์™„๋ฃŒ (์–ธ์–ด: {detected_language}, ์„ธ๊ทธ๋จผํŠธ: {len(all_segments)}๊ฐœ)")
return {
"text": " ".join(full_text_parts),
"segments": all_segments,
"language": detected_language or "unknown",
}
def transcribe_local(
audio_path: str,
model_size: str = "base",
on_progress: Optional[Callable] = None,
) -> dict:
"""๋กœ์ปฌ Whisper ๋ชจ๋ธ๋กœ ์Œ์„ฑ์„ ํ…์ŠคํŠธ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
import whisper
def _notify(percent: int, detail: str):
if on_progress:
on_progress({"step": "transcribe", "percent": percent, "detail": detail})
_notify(0, f"Whisper {model_size} ๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘...")
model = whisper.load_model(model_size)
_notify(20, "์Œ์„ฑ ๋ถ„์„ ์ค‘... (์˜์ƒ ๊ธธ์ด์— ๋”ฐ๋ผ ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค)")
result = model.transcribe(audio_path)
_notify(90, "๊ฒฐ๊ณผ ์ •๋ฆฌ ์ค‘...")
segments = []
for seg in result.get("segments", []):
segments.append(
{"start": seg["start"], "end": seg["end"], "text": seg["text"]}
)
lang = result.get("language", "unknown")
_notify(100, f"๋ณ€ํ™˜ ์™„๋ฃŒ (์–ธ์–ด: {lang}, ์„ธ๊ทธ๋จผํŠธ: {len(segments)}๊ฐœ)")
return {
"text": result["text"],
"segments": segments,
"language": lang,
}
def _format_srt_time(seconds: float) -> str:
"""์ดˆ๋ฅผ SRT ํƒ€์ž„์Šคํƒฌํ”„ ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def save_txt(result: dict, output_path: str) -> str:
"""ํ…์ŠคํŠธ ํŒŒ์ผ๋กœ ์ €์žฅํ•œ๋‹ค."""
with open(output_path, "w", encoding="utf-8") as f:
f.write(result["text"].strip())
return output_path
def save_srt(result: dict, output_path: str) -> str:
"""SRT ์ž๋ง‰ ํŒŒ์ผ๋กœ ์ €์žฅํ•œ๋‹ค."""
with open(output_path, "w", encoding="utf-8") as f:
for i, seg in enumerate(result["segments"], 1):
f.write(f"{i}\n")
f.write(
f"{_format_srt_time(seg['start'])} --> {_format_srt_time(seg['end'])}\n"
)
f.write(f"{seg['text'].strip()}\n\n")
return output_path
def _sanitize_filename(title: str) -> str:
"""ํŒŒ์ผ๋ช…์— ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๋ฌธ์ž๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค."""
sanitized = re.sub(r'[<>:"/\\|?*]', "", title)
sanitized = sanitized.strip(". ")
return sanitized[:100] if sanitized else "untitled"
def process_video(
url: str,
mode: str = "api",
api_key: Optional[str] = None,
model_size: str = "base",
output_dir: str = "./output",
formats: Optional[List[str]] = None,
on_progress: Optional[Callable] = None,
# LLM ์˜ต์…˜ (์ „์—ญ ๊ธฐ๋ณธ๊ฐ’, ํ•˜์œ„ ํ˜ธํ™˜)
md_llm: Optional[str] = None,
md_api_key: Optional[str] = None,
md_ollama_model: str = "llama3.2",
# ๋ฒˆ์—ญ ์˜ต์…˜
translate_lang: Optional[str] = None,
# ๋‹จ๊ณ„๋ณ„ LLM ์˜ค๋ฒ„๋ผ์ด๋“œ
format_llm: Optional[str] = None,
format_api_key: Optional[str] = None,
translate_llm: Optional[str] = None,
translate_api_key: Optional[str] = None,
keyframe_llm: Optional[str] = None,
keyframe_api_key: Optional[str] = None,
# ํ‚คํ”„๋ ˆ์ž„ ์˜ต์…˜
enable_keyframes: bool = False,
keyframe_method: str = "scene",
keyframe_interval: int = 30,
keyframe_max_frames: int = 50,
) -> dict:
"""์ „์ฒด ํŒŒ์ดํ”„๋ผ์ธ: ๋‹ค์šด๋กœ๋“œ โ†’ (ํ‚คํ”„๋ ˆ์ž„) โ†’ ๋ณ€ํ™˜ โ†’ (AI ์ •๋ฆฌ) โ†’ (๋ฒˆ์—ญ) โ†’ ์ €์žฅ."""
from formatter import make_llm_config, format_as_markdown, translate_text, analyze_keyframes
if formats is None:
formats = ["txt", "srt"]
os.makedirs(output_dir, exist_ok=True)
def progress(data):
if on_progress:
on_progress(data)
# ๋‹จ๊ณ„๋ณ„ LLM ์„ค์ • ๋นŒ๋“œ
llm_cfg = make_llm_config(
global_llm=md_llm, global_api_key=md_api_key,
global_ollama_model=md_ollama_model,
format_llm=format_llm, format_api_key=format_api_key,
translate_llm=translate_llm, translate_api_key=translate_api_key,
keyframe_llm=keyframe_llm, keyframe_api_key=keyframe_api_key,
)
# 1. ์˜ค๋””์˜ค ๋‹ค์šด๋กœ๋“œ
audio_info = download_audio(url, on_progress=on_progress)
audio_path = audio_info["audio_path"]
title = audio_info["title"]
video_path = None
keyframe_dir = None
try:
# 2. ํ‚คํ”„๋ ˆ์ž„์šฉ ์˜์ƒ ๋‹ค์šด๋กœ๋“œ (์„ ํƒ ์‹œ)
if enable_keyframes:
video_path = download_video(url, on_progress=on_progress)
# 3. ํ‚คํ”„๋ ˆ์ž„ ์ถ”์ถœ (์„ ํƒ ์‹œ)
keyframe_descriptions = None
if enable_keyframes and video_path:
keyframe_dir = tempfile.mkdtemp()
keyframes = extract_keyframes(
video_path, output_dir=keyframe_dir,
method=keyframe_method,
interval_seconds=keyframe_interval,
max_frames=keyframe_max_frames,
on_progress=on_progress,
)
# 4. ํ‚คํ”„๋ ˆ์ž„ Vision LLM ๋ถ„์„
if keyframes:
kf_cfg = llm_cfg["keyframe"]
keyframe_descriptions = analyze_keyframes(
keyframe_paths=keyframes,
llm_provider=kf_cfg["llm"],
api_key=kf_cfg["api_key"],
on_progress=on_progress,
)
# 5. ์Œ์„ฑ โ†’ ํ…์ŠคํŠธ ๋ณ€ํ™˜
if mode == "api":
if not api_key:
raise ValueError("API ๋ชจ๋“œ์—์„œ๋Š” OpenAI API ํ‚ค๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
result = transcribe_api(audio_path, api_key, on_progress=on_progress)
else:
result = transcribe_local(audio_path, model_size, on_progress=on_progress)
# 6. AI ๋งˆํฌ๋‹ค์šด ์ •๋ฆฌ (MD ์„ ํƒ ์‹œ)
md_content = None
fmt_cfg = llm_cfg["format"]
if "md" in formats and fmt_cfg["llm"]:
md_content = format_as_markdown(
text=result["text"],
title=title,
llm_provider=fmt_cfg["llm"],
api_key=fmt_cfg["api_key"],
ollama_model=fmt_cfg["ollama_model"],
on_progress=on_progress,
keyframe_descriptions=keyframe_descriptions,
)
# 7. ๋ฒˆ์—ญ (์„ ํƒ ์‹œ)
translated_txt = None
translated_md = None
tr_cfg = llm_cfg["translate"]
if translate_lang and tr_cfg["llm"]:
translated_txt = translate_text(
text=result["text"],
target_lang=translate_lang,
llm_provider=tr_cfg["llm"],
api_key=tr_cfg["api_key"],
ollama_model=tr_cfg["ollama_model"],
on_progress=on_progress,
)
if md_content:
translated_md = translate_text(
text=md_content,
target_lang=translate_lang,
llm_provider=tr_cfg["llm"],
api_key=tr_cfg["api_key"],
ollama_model=tr_cfg["ollama_model"],
on_progress=on_progress,
)
# 8. ํŒŒ์ผ ์ €์žฅ
progress({"step": "save", "percent": 0, "detail": "ํŒŒ์ผ ์ €์žฅ ์ค‘..."})
safe_title = _sanitize_filename(title)
saved_files = {}
if "txt" in formats:
txt_path = os.path.join(output_dir, f"{safe_title}.txt")
save_txt(result, txt_path)
saved_files["txt"] = txt_path
if "srt" in formats:
srt_path = os.path.join(output_dir, f"{safe_title}.srt")
save_srt(result, srt_path)
saved_files["srt"] = srt_path
if "md" in formats and md_content:
md_path = os.path.join(output_dir, f"{safe_title}.md")
with open(md_path, "w", encoding="utf-8") as f:
f.write(md_content)
saved_files["md"] = md_path
# ๋ฒˆ์—ญ ํŒŒ์ผ ์ €์žฅ
if translated_txt:
lang_suffix = translate_lang.replace("-", "").lower()
tr_txt_path = os.path.join(output_dir, f"{safe_title}_{lang_suffix}.txt")
with open(tr_txt_path, "w", encoding="utf-8") as f:
f.write(translated_txt)
saved_files[f"txt_{lang_suffix}"] = tr_txt_path
if translated_md:
lang_suffix = translate_lang.replace("-", "").lower()
tr_md_path = os.path.join(output_dir, f"{safe_title}_{lang_suffix}.md")
with open(tr_md_path, "w", encoding="utf-8") as f:
f.write(translated_md)
saved_files[f"md_{lang_suffix}"] = tr_md_path
fmt_str = ", ".join(f.upper() for f in saved_files.keys())
progress({"step": "save", "percent": 100, "detail": f"{fmt_str} ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ"})
return {
"title": title,
"language": result["language"],
"text": result["text"],
"segments": result["segments"],
"files": saved_files,
}
finally:
# ์ž„์‹œ ํŒŒ์ผ ์ •๋ฆฌ
if os.path.exists(audio_path):
os.remove(audio_path)
if video_path and os.path.exists(video_path):
os.remove(video_path)
if keyframe_dir and os.path.exists(keyframe_dir):
shutil.rmtree(keyframe_dir, ignore_errors=True)