BrainGPT / pipeline_core.py
brainproject's picture
Upload pipeline_core.py
2614744 verified
Raw
History Blame Contribute Delete
19.9 kB
"""
BrainGPT pipeline core (demo build).
Adds: selectable voice, debug log file, saved transcript, end-of-run report.
"""
import subprocess, json, os, time, base64, tempfile, requests, threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import anthropic
from faster_whisper import WhisperModel
# Parallelism (scheduling only β€” does not change output, just speed)
CORRECT_WORKERS = 4 # concurrent Claude correction batches
TTS_WORKERS = 3 # concurrent MiniMax voice requests
FRAME_WORKERS = 4 # concurrent screenshot extractions
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
WHISPER_REPOS = {
"base": ("Systran/faster-whisper-base", "faster-whisper-base"),
"small": ("Systran/faster-whisper-small", "faster-whisper-small"),
"large-v3-turbo": ("mobiuslabsgmbh/faster-whisper-large-v3-turbo","faster-whisper-large-v3-turbo"),
}
def _model_dir(name: str) -> Path:
base = Path(os.environ.get("LOCALAPPDATA", str(Path.home())))
return base / "BrainGPT" / "models" / name
def _existing_model(folder: str):
"""Reuse a model already downloaded by any BrainGPT app, to avoid re-downloading."""
base = Path(os.environ.get("LOCALAPPDATA", str(Path.home())))
for root in ("BrainGPT", "YouTubePipeline"):
cand = base / root / "models" / folder
if (cand / "model.bin").exists():
return cand
return None
def ensure_model(model_key="base", log=None) -> str:
repo, folder = WHISPER_REPOS.get(model_key, WHISPER_REPOS["base"])
found = _existing_model(folder)
if found:
return str(found)
md = _model_dir(folder)
if (md / "model.bin").exists():
return str(md)
if log:
log("Downloading AI model", f"Downloading {model_key} model (one time)…")
md.parent.mkdir(parents=True, exist_ok=True)
# Be patient with slow/unstable connections
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "60")
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "30")
from huggingface_hub import snapshot_download
# Try the official endpoint first, then a public mirror if it keeps timing out.
# snapshot_download resumes partial files, so retries don't restart from zero.
endpoints = [None, None, "https://hf-mirror.com", "https://hf-mirror.com"]
last_err = None
for attempt, endpoint in enumerate(endpoints):
try:
if endpoint:
os.environ["HF_ENDPOINT"] = endpoint
if log:
log("Downloading AI model", "Primary server slow β€” trying mirror…")
snapshot_download(repo_id=repo, local_dir=str(md), max_workers=2)
os.environ.pop("HF_ENDPOINT", None)
return str(md)
except Exception as e:
last_err = e
if log:
log("Downloading AI model",
f"Network issue β€” retrying ({attempt + 1}/{len(endpoints)})…")
time.sleep(5 * (attempt + 1))
os.environ.pop("HF_ENDPOINT", None)
raise RuntimeError(
"Couldn't download the AI voice-to-text model β€” the servers could not be "
"reached.\n\n"
"β€’ Check your internet connection.\n"
"β€’ If you use a VPN, firewall, or are on a restricted network, that may be "
"blocking the download β€” try another network.\n"
"β€’ Then run it again (it resumes where it left off).\n\n"
f"Details: {last_err}")
def _run(cmd, **kw):
return subprocess.run(cmd, check=True, capture_output=True, **kw)
def get_duration(path: str) -> float:
r = subprocess.run(["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", path], capture_output=True, text=True, check=True)
for s in json.loads(r.stdout)["streams"]:
if "duration" in s:
return float(s["duration"])
return 0.0
def make_thumbnail(video_path: str, out_path: str, at: float = 3.0):
try:
_run(["ffmpeg", "-ss", str(at), "-i", video_path, "-frames:v", "1",
"-vf", "scale=320:-1", out_path, "-y"])
return out_path
except Exception:
return None
# ── Step 1 ────────────────────────────────────────────────────────────────────
def extract_audio(video_path, audio_path):
_run(["ffmpeg", "-i", video_path, "-vn", "-acodec", "pcm_s16le",
"-ar", "16000", "-ac", "1", audio_path, "-y"])
# ── Step 2 ────────────────────────────────────────────────────────────────────
def _fmt(sec):
m, s = divmod(int(sec), 60)
return f"{m}:{s:02d}"
def _pick_device():
"""Use the GPU if an NVIDIA card is available (same model, same accuracy, much faster)."""
try:
import ctranslate2
if ctranslate2.get_cuda_device_count() > 0:
return "cuda", "float16"
except Exception:
pass
return "cpu", "int8"
def _encode_video(video_path, vf_filter, out_path, log=None):
"""Render the speed-adjusted video. Uses the GPU's NVENC encoder when available
(much faster on long videos), and falls back to CPU x264 anywhere else."""
base = ["ffmpeg", "-i", video_path, "-filter_complex", vf_filter, "-map", "[vout]"]
device, _ = _pick_device()
if device == "cuda":
try:
if log:
log("Building video", "Matching video speed to voice (GPU encode)…")
subprocess.run(
base + ["-c:v", "h264_nvenc", "-preset", "p5",
"-rc", "vbr", "-cq", "23", "-pix_fmt", "yuv420p",
out_path, "-y"],
check=True, capture_output=True)
return
except Exception:
if log:
log("Building video", "GPU encoder unavailable β€” using CPU…")
subprocess.run(
base + ["-c:v", "libx264", "-preset", "fast", out_path, "-y"],
check=True)
def transcribe(audio_path, log, model_key="base"):
log("Transcribing", "Loading model…")
path = ensure_model(model_key, log)
device, compute = _pick_device()
try:
model = WhisperModel(path, device=device, compute_type=compute)
except Exception:
device, compute = "cpu", "int8" # GPU present but unusable β†’ fall back
model = WhisperModel(path, device=device, compute_type=compute)
log("Transcribing", f"Listening to audio (using {device.upper()})…")
gen, info = model.transcribe(audio_path, beam_size=5)
total = info.duration or 0
segs = []
for i, s in enumerate(gen):
segs.append({"id": i, "start": s.start, "end": s.end, "text": s.text.strip()})
if total:
pct = min(100, int(s.end / total * 100))
log("Transcribing", f"{pct}% ({_fmt(s.end)} / {_fmt(total)})")
return segs
# ── Step 3 ────────────────────────────────────────────────────────────────────
CORRECTION_RULES = (
"You are correcting a screen-recording tutorial transcript. "
"Use the screenshots to understand the topic before correcting.\n\n"
"Rules:\n"
"1. Fix spelling and grammar.\n"
"2. Use the screenshots to correct misheard on-screen names, buttons, or terms.\n"
"3. If a sentence makes no sense for the video, rewrite it using the screenshots as a guide.\n"
"4. If a segment repeats the previous one, rewrite it to continue naturally.\n"
"5. Keep the speaker's natural voice. Do not add new content.\n\n"
"Return ONLY a JSON array with the SAME ids: [{\"id\":0,\"text\":\"...\"},...]. "
"No markdown, no explanation."
)
def grab_frame(video_path, ts, out):
_run(["ffmpeg", "-ss", str(ts), "-i", video_path, "-frames:v", "1",
"-vf", "scale=768:-1", "-q:v", "5", out, "-y"])
def _extract_json_array(raw):
raw = raw.strip()
if raw.startswith("```"):
parts = raw.split("```")
if len(parts) >= 2:
raw = parts[1]
if raw.lstrip().lower().startswith("json"):
raw = raw.lstrip()[4:]
raw = raw.strip()
a, b = raw.find("["), raw.rfind("]")
if a != -1 and b != -1 and b > a:
raw = raw[a:b + 1]
return json.loads(raw)
def correct_transcript(segments, video_path, tmpdir, log, api_key, report):
client = anthropic.Anthropic(api_key=api_key)
n = len(segments)
# 1) Extract every screenshot in parallel (same frames β†’ same accuracy)
log("Correcting transcript", "Capturing screenshots…")
def _frame(seg):
mid = (seg["start"] + seg["end"]) / 2
fp = os.path.join(tmpdir, f"frame_{seg['id']}.jpg")
try:
grab_frame(video_path, mid, fp)
return seg["id"], fp
except Exception:
return seg["id"], None
frames = {}
with ThreadPoolExecutor(max_workers=FRAME_WORKERS) as ex:
for sid, fp in ex.map(_frame, segments):
frames[sid] = fp
# 2) Correct independent batches concurrently (same prompts/model β†’ same accuracy)
BATCH = 20
batches = [segments[i:i + BATCH] for i in range(0, n, BATCH)]
done = {"c": 0}
lock = threading.Lock()
def _do_batch(batch):
content = [{"type": "text", "text": CORRECTION_RULES}]
for seg in batch:
fp = frames.get(seg["id"])
if fp and os.path.exists(fp):
with open(fp, "rb") as f:
b64 = base64.standard_b64encode(f.read()).decode()
content.append({"type": "image", "source": {"type": "base64",
"media_type": "image/jpeg", "data": b64}})
content.append({"type": "text", "text": f"Segment id {seg['id']}: {seg['text']}"})
cmap = {}
for attempt in range(2):
try:
msg = client.messages.create(model="claude-sonnet-4-6", max_tokens=4096,
messages=[{"role": "user", "content": content}])
arr = _extract_json_array(msg.content[0].text)
cmap = {it["id"]: it["text"] for it in arr
if isinstance(it, dict) and "id" in it and "text" in it}
if cmap:
break
except Exception:
if attempt == 0:
time.sleep(2)
continue
with lock:
done["c"] += len(batch)
log("Correcting transcript", f"{min(done['c'], n)} / {n} segments…")
return batch, cmap
failed = 0
with ThreadPoolExecutor(max_workers=CORRECT_WORKERS) as ex:
for batch, cmap in ex.map(_do_batch, batches):
if not cmap:
failed += len(batch)
for seg in batch:
seg["corrected"] = cmap.get(seg["id"], seg["text"])
if failed:
report["skipped"].append(f"Grammar correction skipped for {failed} segment(s) β€” kept original text")
report["corrected_ok"] = n - failed
return segments
# ── Steps 4-6 ─────────────────────────────────────────────────────────────────
def generate_tts(text, path, mm_key, mm_group, voice_id, model, speed):
url = f"https://api.minimaxi.chat/v1/t2a_v2?GroupId={mm_group}"
headers = {"Authorization": f"Bearer {mm_key}", "Content-Type": "application/json"}
body = {"model": model, "text": text, "stream": False,
"voice_setting": {"voice_id": voice_id, "speed": speed, "vol": 1.0, "pitch": 0},
"audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "mp3"}}
last_err = None
for attempt in range(8):
try:
data = requests.post(url, headers=headers, json=body, timeout=60).json()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
last_err = e
time.sleep(8 * (attempt + 1))
continue
hex_audio = data.get("data", {}).get("audio")
if hex_audio:
with open(path, "wb") as f:
f.write(bytes.fromhex(hex_audio))
return True
if data.get("base_resp", {}).get("status_code") == 1002:
time.sleep(10 * (attempt + 1))
else:
raise ValueError(f"MiniMax error: {data}")
raise ValueError(f"MiniMax failed. Last network error: {last_err}")
def build_output(segments, video_path, video_duration, tmpdir, output_path,
log, mm_key, mm_group, voice, report):
before = len(segments)
segments = [s for s in segments if s.get("corrected", "").strip()]
empty_skipped = before - len(segments)
if empty_skipped:
report["skipped"].append(f"Removed {empty_skipped} silent/empty segment(s)")
total = len(segments)
done = {"c": 0}
lock = threading.Lock()
def _voice(item):
i, seg = item
tts_path = os.path.join(tmpdir, f"tts_{i}.mp3")
ok = False
try:
generate_tts(seg["corrected"], tts_path, mm_key, mm_group,
voice["voice_id"], voice.get("model", "speech-02-hd"),
float(voice.get("speed", 1.0)))
dur = get_duration(tts_path)
if dur >= 0.05:
seg["tts_path"] = tts_path
seg["tts_dur"] = dur
ok = True
except Exception:
ok = False
if not ok:
seg["skip"] = True
with lock:
done["c"] += 1
log("Generating voice", f"{min(done['c'], total)} / {total} clips…")
return ok
# Generate voice clips concurrently (no fixed delay; generate_tts self-throttles
# on rate limits). Output audio is identical β€” only the scheduling changes.
with ThreadPoolExecutor(max_workers=TTS_WORKERS) as ex:
results = list(ex.map(_voice, list(enumerate(segments))))
tts_failed = sum(1 for r in results if not r)
segments = [s for s in segments if not s.get("skip")]
if tts_failed:
report["skipped"].append(f"{tts_failed} voice clip(s) failed and were skipped")
# Timeline
chunks, orig_pos, new_pos = [], 0.0, 0.0
for seg in segments:
gap = seg["start"] - orig_pos
if gap > 0.01:
chunks.append({"os": orig_pos, "od": gap, "ns": new_pos, "nd": gap, "tts": None})
new_pos += gap
od = seg["end"] - seg["start"]
chunks.append({"os": seg["start"], "od": od, "ns": new_pos, "nd": seg["tts_dur"],
"tts": seg["tts_path"]})
new_pos += seg["tts_dur"]
orig_pos = seg["end"]
tail = video_duration - orig_pos
if tail > 0.01:
chunks.append({"os": orig_pos, "od": tail, "ns": new_pos, "nd": tail, "tts": None})
new_pos += tail
total_duration = new_pos
# Speed-adjust video
log("Building video", "Matching video speed to voice…")
vf, vl = [], []
for i, c in enumerate(chunks):
factor = c["nd"] / c["od"]
vf.append(f"[0:v]trim=start={c['os']:.4f}:duration={c['od']:.4f},"
f"setpts={factor:.6f}*(PTS-STARTPTS)[v{i}]")
vl.append(f"[v{i}]")
vf.append(f"{''.join(vl)}concat=n={len(chunks)}:v=1:a=0[vout]")
processed = os.path.join(tmpdir, "video.mp4")
_encode_video(video_path, ";".join(vf), processed, log)
# Audio
log("Building audio", "Mixing voice track…")
tts_chunks = [(c["tts"], c["ns"]) for c in chunks if c["tts"]]
ai = ["-f", "lavfi", "-i", "anullsrc=r=32000:cl=mono"]
af = []
for idx, (tp, st) in enumerate(tts_chunks):
ai += ["-i", tp]
ms = int(st * 1000)
af.append(f"[{idx+1}]adelay={ms}|{ms}[a{idx}]")
all_a = "[0]" + "".join(f"[a{i}]" for i in range(len(tts_chunks)))
af.append(f"{all_a}amix=inputs={1+len(tts_chunks)}:normalize=0[aout]")
final_audio = os.path.join(tmpdir, "audio.mp3")
subprocess.run(["ffmpeg"] + ai + ["-filter_complex", ";".join(af), "-map", "[aout]",
"-t", str(total_duration), final_audio, "-y"], check=True)
# Merge
log("Finalizing", "Merging video and audio…")
subprocess.run(["ffmpeg", "-i", processed, "-i", final_audio, "-c:v", "copy",
"-map", "0:v:0", "-map", "1:a:0", "-shortest", output_path, "-y"],
check=True)
report["voiced_segments"] = total - tts_failed
return segments
# ── Public entry ──────────────────────────────────────────────────────────────
def process_video(video_path, output_path, keys, voice, settings,
progress=None, logfile=None) -> dict:
"""Returns a report dict. progress(step, detail) for UI updates."""
logf = open(logfile, "w", encoding="utf-8") if logfile else None
def log(step, detail=""):
line = f"[{time.strftime('%H:%M:%S')}] {step}: {detail}"
if logf:
logf.write(line + "\n"); logf.flush()
if progress:
progress(step, detail)
report = {"skipped": [], "stages": {}, "total_segments": 0,
"corrected_ok": 0, "voiced_segments": 0,
"transcript_path": None, "output": output_path}
api_key = keys.get("ANTHROPIC_API_KEY", "")
mm_key = keys.get("MINIMAX_API_KEY", "")
mm_group = keys.get("MINIMAX_GROUP_ID", "")
for nm, val in [("ANTHROPIC_API_KEY", api_key), ("MINIMAX_API_KEY", mm_key),
("MINIMAX_GROUP_ID", mm_group)]:
if not val:
raise ValueError(f"Missing API key: {nm}")
if not voice or not voice.get("voice_id"):
raise ValueError("No voice selected")
try:
with tempfile.TemporaryDirectory() as tmp:
t0 = time.time()
log("Extracting audio", "")
audio = os.path.join(tmp, "audio.wav")
extract_audio(video_path, audio)
video_dur = get_duration(video_path)
report["stages"]["extract"] = round(time.time() - t0, 1)
t1 = time.time()
segs = transcribe(audio, log, settings.get("whisper_model", "base"))
report["total_segments"] = len(segs)
report["stages"]["transcribe"] = round(time.time() - t1, 1)
if not segs:
report["skipped"].append("No speech detected β€” nothing to voice")
raise ValueError("No speech detected in this video")
t2 = time.time()
log("Correcting transcript", "")
segs = correct_transcript(segs, video_path, tmp, log, api_key, report)
report["stages"]["correct"] = round(time.time() - t2, 1)
# Save corrected transcript next to output
t_path = str(Path(output_path).with_suffix("")) + "_transcript.txt"
try:
with open(t_path, "w", encoding="utf-8") as tf:
tf.write("\n".join(s.get("corrected", s["text"]) for s in segs))
report["transcript_path"] = t_path
except Exception:
pass
t3 = time.time()
build_output(segs, video_path, video_dur, tmp, output_path, log,
mm_key, mm_group, voice, report)
report["stages"]["render"] = round(time.time() - t3, 1)
log("Done", "Complete")
finally:
if logf:
logf.close()
return report