chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# Video transcription using faster-whisper
|
||||
# Converts video/audio files to text transcripts for graph extraction
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.paths import out_path as _out_path
|
||||
|
||||
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
|
||||
URL_PREFIXES = ('http://', 'https://', 'www.')
|
||||
|
||||
_DEFAULT_MODEL = "base"
|
||||
_TRANSCRIPTS_DIR = str(_out_path("transcripts"))
|
||||
_FALLBACK_PROMPT = "Use proper punctuation and paragraph breaks."
|
||||
|
||||
|
||||
def _model_name() -> str:
|
||||
return os.environ.get("GRAPHIFY_WHISPER_MODEL", _DEFAULT_MODEL)
|
||||
|
||||
|
||||
def _get_whisper():
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
return WhisperModel
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Video transcription requires faster-whisper. "
|
||||
"Run: pip install 'graphifyy[video]'"
|
||||
) from exc
|
||||
|
||||
|
||||
def _get_yt_dlp():
|
||||
try:
|
||||
import yt_dlp
|
||||
return yt_dlp
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"YouTube/URL download requires yt-dlp. "
|
||||
"Run: pip install 'graphifyy[video]'"
|
||||
) from exc
|
||||
|
||||
|
||||
def is_url(path: str) -> bool:
|
||||
"""Return True if the string looks like a URL rather than a file path."""
|
||||
return any(path.startswith(p) for p in URL_PREFIXES)
|
||||
|
||||
|
||||
def download_audio(url: str, output_dir: Path) -> Path:
|
||||
"""Download audio-only stream from a URL using yt-dlp.
|
||||
|
||||
Returns the path to the downloaded audio file (.m4a or .opus).
|
||||
Uses cached file if already downloaded.
|
||||
"""
|
||||
from graphify.security import validate_url
|
||||
validate_url(url) # blocks private IPs, bad schemes before yt-dlp runs
|
||||
yt_dlp = _get_yt_dlp()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# yt-dlp uses %(title)s which can be long/weird — use a stable name based on URL hash
|
||||
import hashlib
|
||||
url_hash = hashlib.sha1(url.encode(), usedforsecurity=False).hexdigest()[:12]
|
||||
out_template = str(output_dir / f"yt_{url_hash}.%(ext)s")
|
||||
|
||||
# Check for already-downloaded file
|
||||
for ext in ('.m4a', '.opus', '.mp3', '.ogg', '.wav', '.webm'):
|
||||
candidate = output_dir / f"yt_{url_hash}{ext}"
|
||||
if candidate.exists():
|
||||
print(f" cached audio: {candidate.name}")
|
||||
return candidate
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio[ext=m4a]/bestaudio/best',
|
||||
'outtmpl': out_template,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'noplaylist': True,
|
||||
'postprocessors': [], # no ffmpeg needed — use native audio
|
||||
}
|
||||
|
||||
print(f" downloading audio: {url[:80]} ...", flush=True)
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
ext = info.get('ext', 'm4a')
|
||||
downloaded = output_dir / f"yt_{url_hash}.{ext}"
|
||||
if not downloaded.exists():
|
||||
# yt-dlp may have picked a different extension
|
||||
for p in output_dir.glob(f"yt_{url_hash}.*"):
|
||||
downloaded = p
|
||||
break
|
||||
return downloaded
|
||||
|
||||
|
||||
def build_whisper_prompt(god_nodes: list[dict]) -> str:
|
||||
"""Build a domain hint for Whisper from god nodes extracted from the corpus.
|
||||
|
||||
Formats the top god node labels into a topic string for Whisper.
|
||||
The coding agent (Claude Code, Codex, etc.) generates the actual one-sentence
|
||||
domain hint from these labels and passes it via GRAPHIFY_WHISPER_PROMPT or
|
||||
as initial_prompt — no separate API call needed here.
|
||||
"""
|
||||
if not god_nodes:
|
||||
return _FALLBACK_PROMPT
|
||||
|
||||
override = os.environ.get("GRAPHIFY_WHISPER_PROMPT")
|
||||
if override:
|
||||
return override
|
||||
|
||||
labels = [n.get("label", "") for n in god_nodes[:10] if n.get("label")]
|
||||
if not labels:
|
||||
return _FALLBACK_PROMPT
|
||||
|
||||
topics = ", ".join(labels[:5])
|
||||
return f"Technical discussion about {topics}. Use proper punctuation and paragraph breaks."
|
||||
|
||||
|
||||
def transcribe(
|
||||
video_path: Path | str,
|
||||
output_dir: Path | None = None,
|
||||
initial_prompt: str | None = None,
|
||||
force: bool = False,
|
||||
) -> Path:
|
||||
"""Transcribe a video/audio file or URL to a .txt transcript.
|
||||
|
||||
If video_path is a URL, audio is downloaded first via yt-dlp.
|
||||
Returns the path to the saved transcript file.
|
||||
Uses cached transcript if it exists unless force=True.
|
||||
|
||||
initial_prompt: domain hint for Whisper (built from corpus god nodes).
|
||||
force: re-transcribe even if transcript already exists.
|
||||
"""
|
||||
out_dir = Path(output_dir) if output_dir else Path(_TRANSCRIPTS_DIR)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if is_url(str(video_path)):
|
||||
audio_path = download_audio(str(video_path), out_dir / "downloads")
|
||||
else:
|
||||
audio_path = Path(video_path)
|
||||
|
||||
transcript_path = out_dir / (audio_path.stem + ".txt")
|
||||
if transcript_path.exists() and not force:
|
||||
return transcript_path
|
||||
|
||||
WhisperModel = _get_whisper()
|
||||
model_name = _model_name()
|
||||
prompt = initial_prompt or _FALLBACK_PROMPT
|
||||
|
||||
print(f" transcribing {audio_path.name} (model={model_name}) ...", flush=True)
|
||||
model = WhisperModel(model_name, device="cpu", compute_type="int8")
|
||||
segments, info = model.transcribe(
|
||||
str(audio_path),
|
||||
beam_size=5,
|
||||
initial_prompt=prompt,
|
||||
)
|
||||
|
||||
lines = [segment.text.strip() for segment in segments if segment.text.strip()]
|
||||
transcript = "\n".join(lines)
|
||||
|
||||
transcript_path.write_text(transcript, encoding="utf-8")
|
||||
lang = info.language if hasattr(info, "language") else "unknown"
|
||||
print(f" transcript saved -> {transcript_path} (lang={lang}, {len(lines)} segments)")
|
||||
return transcript_path
|
||||
|
||||
|
||||
def transcribe_all(
|
||||
video_files: list[str],
|
||||
output_dir: Path | None = None,
|
||||
initial_prompt: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Transcribe a list of video/audio files or URLs, return paths to transcript .txt files.
|
||||
|
||||
Already-transcribed files are returned from cache instantly.
|
||||
initial_prompt is shared across all files — built once from corpus god nodes.
|
||||
"""
|
||||
if not video_files:
|
||||
return []
|
||||
|
||||
transcript_paths = []
|
||||
for vf in video_files:
|
||||
try:
|
||||
t = transcribe(vf, output_dir, initial_prompt=initial_prompt)
|
||||
transcript_paths.append(str(t))
|
||||
except Exception as exc:
|
||||
print(f" warning: could not transcribe {vf}: {exc}")
|
||||
return transcript_paths
|
||||
Reference in New Issue
Block a user