created remote transcriber

This commit is contained in:
SumGuyV5 committed 2026-09-24 19:53:05 +00:00
1 parent 856ab439f7
commit 38e0cc6764
10 files changed
+718 -68

No files matched your search

+3 -1
View File
@@ -20,6 +20,7 @@ from .config import CHANNEL_NAME, TZ, DB
from .hash_tags import get_top_nouns
from .process_uploads import process_uploads
from .transcribe_video import transcribe_to_srt
from .transcribe_client import transcribe_to_srt_remote
from .utils import get_chapters, get_video_id_from_chat, write_csv, run_command
# 4. Expose Subpackages
@@ -36,7 +37,8 @@ __all__ = [
"DB",
"get_top_nouns",
"process_uploads",
"transcribe_to_srt",
"transcribe_to_srt",
"transcribe_to_srt_remote",
"write_csv",
"run_command",
"get_chapters",
+34 -5
View File
@@ -3,7 +3,9 @@ from datetime import datetime
import package.video_editing.build_shorts as yt_shorts
import package.transcribe_video as transcribe_video
import package.transcribe_client as transcribe_client
import package.twitch.chat_vod as chat_vod
import package.video_editing.highlights as highlights
from package.config import DB as DB
from package.config import TZ as TZ
@@ -22,19 +24,20 @@ def build_chat_video():
print("====================================================")
def build_transcribe():
unuploadeds = DB.get_unuploaded()
downloads = DB.get_downloaded()
for unuploaded in unuploadeds:
if unuploaded.clip_is:
for download in downloads:
if download.clip_is:
target_dir = f"download/clips"
else:
target_dir = f"download/videos"
print("====================================================")
print(f"🚀 Transcribe: {unuploaded.title}")
print(f"🚀 Transcribe: {download.title}")
print("====================================================")
transcribe_video.transcribe_to_srt(f"{target_dir}/{unuploaded.id}/{unuploaded.id}.mp4")
#transcribe_video.transcribe_to_srt(f"{target_dir}/{unuploaded.id}/{unuploaded.id}.mp4", True)
transcribe_client.transcribe_to_srt_remote(f"{target_dir}/{download.id}/{download.id}.mp4")
print("====================================================")
print(f"✅ Processing: Transcribing Done.")
@@ -65,6 +68,32 @@ def build_shorts():
print(f"✅ Processing: Clip to Youtube Short Done.")
print("====================================================")
def build_highlights():
lights = DB.get_downloaded()
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
count = 1
for light in lights:
target_dir = f"download/clips/{light.id}/{light.id}.mp4"
print("====================================================")
print(f"🚀 Processing: Highlights {light.title}")
print("====================================================")
print(f"{count}/{len(lights)}")
created_at = datetime.strptime(light.created_at, "%Y-%m-%d %H:%M:%S%z")
created_at = created_at.astimezone(TZ)
#ToDo
highlights.highlights(target_dir, [], 5, 8)
#yt_shorts.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {light.creator_name},\n on {created_at.strftime("%Y-%m-%d %H:%M:%S %Z")}")
count += 1
print("====================================================")
print(f"✅ Processing: Clip to Youtube Short Done.")
print("====================================================")
def build_videos():
build_shorts()
build_transcribe()
+13
View File
@@ -25,12 +25,14 @@ class TwitchVideo(Base):
uploaded_yt = Column(Boolean, nullable=False, default=False)
uploaded_yt_chats = Column(Boolean, nullable=False, default=False)
uploaded_yt_shorts = Column(Boolean, nullable=False, default=False)
uploaded_tiktok = Column(Boolean, nullable=False, default=False)
# This now actually points to twitch_videos.id
related_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=True)
# Relationship back to YoutubeInfo (uselist=True enforces a 1-to-many structure)
youtube_info = relationship("YoutubeInfo", back_populates="twitch_video", uselist=True)
tiktok_info = relationship("TiktokInfo", back_populates="twitch_video", uselist=True)
chapters = relationship("Chapter", back_populates="twitch_video", cascade="all, delete-orphan")
# 3. Define the data table
@@ -61,6 +63,17 @@ class YoutubeInfo(Base):
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="youtube_info")
class TiktokInfo(Base):
__tablename__ = "tiktok_info"
id = Column(Integer, primary_key=True, autoincrement=True)
twitch_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=False)
tiktok_id = Column(String, nullable=True)
tiktok_date = Column(String, nullable=True)
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="tiktok_info")
class Chapter(Base):
__tablename__ = "chapters"
+4 -2
View File
@@ -21,7 +21,8 @@ def process_videos(video_type: bool = True, limit: int = 22):
meta_data: Data = DB.get_data()[0]
current_time = datetime.now(TZ)
release_time = datetime.strptime(meta_data.date_shorts, "%Y-%m-%d %H:%M:%S%z")
next_time = meta_data.date_videos if video_type else meta_data.date_shorts
release_time = datetime.strptime(next_time, "%Y-%m-%d %H:%M:%S%z")
if current_time > release_time:
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
@@ -117,7 +118,8 @@ def process_videos(video_type: bool = True, limit: int = 22):
youtube_info.yt_short_date = datetime.now(TZ)
DB.insert(youtube_info)
release_time += timedelta(days=1)
#release_time += timedelta(days=1)
release_time += timedelta(hours=12) # release every 12 hours instead of every day
# Update global release pointer once at end
if video_type:
+9
View File
@@ -0,0 +1,9 @@
# package/tiktok/__init__.py
# Expose components from individual modules using relative imports
from .uploader import upload_to_tiktok
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"upload_to_tiktok"
]
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
from tiktok_uploader.upload import upload_video
from pathlib import Path
import json
import time
from playwright.sync_api import sync_playwright
COOKIES_FILE = "cookies.txt"
def get_last_video_link(username):
"""Grab newest video link from profile"""
url = f"https://www.tiktok.com/@{username}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(storage_state=COOKIES_FILE) if Path(COOKIES_FILE).exists() else browser.new_context()
page = context.new_page()
page.goto(url, timeout=60000)
page.wait_for_selector('[data-e2e="user-post-item"] a', timeout=15000)
first_video = page.query_selector('[data-e2e="user-post-item"] a')
link = first_video.get_attribute('href') if first_video else None
browser.close()
return link
def upload_to_tiktok(video_path, description, hashtags=[], username="YOUR_USERNAME_HERE"):
tags = " ".join([f"#{t}" for t in hashtags])
full_desc = f"{description}\n\n{tags}"
failed = upload_video(
filename=str(video_path),
description=full_desc,
cookies=COOKIES_FILE,
headless=True,
browser="chrome"
)
if len(failed) != 0:
print(f"❌ Failed {video_path}: {failed}")
return None
print(f"✅ Uploaded {video_path} - fetching link...")
time.sleep(8) # give TikTok time to process
# Try 3 times, TikTok takes ~10-30s to show new video
for i in range(3):
link = get_last_video_link(username)
if link:
print(f"🔗 {link}")
return link
print(f" Waiting for TikTok to publish... retry {i+1}/3")
time.sleep(10)
return f"https://www.tiktok.com/@{username} (uploaded, but link not ready yet - check profile)"
if __name__ == "__main__":
USERNAME = "your_tiktok_username" # <-- CHANGE THIS, no @
with open("highlights.json") as f:
data = json.load(f)
for h in data['highlights'][:3]:
video_file = f"highlights/highlight_{h['id']}_{h['start_timestamp'].replace(':','-')}.mp4"
if not Path(video_file).exists():
continue
desc = h['transcript'][:150]
link = upload_to_tiktok(
video_file,
description=desc,
hashtags=["fyp", "clutch", "highlight", "pog"],
username=USERNAME
)
print(f"LINK: {link}\n")
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
#!/usr/bin/env python3
import threading
# --- HOTFIX for Python 3.13.5 _DeleteDummyThreadOnDel spam ---
# The CPython fix in 3.13.4 didn't fully kill it for debugpy/asyncio cases
if hasattr(threading, '_DeleteDummyThreadOnDel'):
threading._DeleteDummyThreadOnDel.__del__ = lambda self: None
import os
import shutil
# ... rest of your imports
import os
import shutil
import subprocess
import json
from pathlib import Path
import wave
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
CHUNK_DURATION_SEC = 600 # target chunk size
OVERLAP_SEC = 8 # overlap to prevent cut words
VAD_SEARCH_WINDOW = 15 # look for silence within 15s of target
WORKER_URL = "http://192.168.5.55:8000/transcribe"
MAX_WORKERS = 4 # perfect for 4070 Ti Super 16GB
def get_duration(video_path: str) -> float:
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", video_path]
result = subprocess.run(cmd, capture_output=True, text=True)
return float(json.loads(result.stdout)["format"]["duration"])
def find_silence_near(video_path: str, target_time: float) -> float:
"""Find nearest silence within VAD_SEARCH_WINDOW of target_time"""
start_search = max(0, target_time - VAD_SEARCH_WINDOW/2)
cmd = [
"ffmpeg", "-ss", str(start_search), "-t", str(VAD_SEARCH_WINDOW),
"-i", video_path, "-af", "silencedetect=noise=-30dB:d=0.5",
"-f", "null", "-"
]
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
# Parse last silence end before target
best = target_time
for line in result.stderr.splitlines():
if "silence_end:" in line:
try:
t = float(line.split("silence_end:")[1].split("|")[0].strip())
abs_t = start_search + t
if abs(abs_t - target_time) < abs(best - target_time):
best = abs_t
except: pass
return best
def extract_audio_and_chunk_smart(video_path: str, output_dir: Path):
print(f"🚀 Smart chunking: target {CHUNK_DURATION_SEC}s + {OVERLAP_SEC}s overlap")
output_dir.mkdir(parents=True, exist_ok=True)
total_dur = get_duration(video_path)
print(f"📏 Total duration: {total_dur/60:.1f} min")
chunks = []
start = 0.0
idx = 0
while start < total_dur:
# Find smart cut point
target_end = start + CHUNK_DURATION_SEC
if target_end >= total_dur:
end = total_dur
else:
end = find_silence_near(video_path, target_end)
# clamp
end = max(start + CHUNK_DURATION_SEC * 0.7, min(end, start + CHUNK_DURATION_SEC * 1.3))
chunk_path = output_dir / f"chunk_{idx:03d}.wav"
# Extract with overlap for next chunk
extract_end = end + (OVERLAP_SEC if end < total_dur else 0)
cmd = [
"ffmpeg", "-y", "-ss", str(start), "-to", str(extract_end),
"-i", video_path, "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le",
str(chunk_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
chunks.append((chunk_path, start, end)) # path, true_start, true_end (without overlap)
print(f" Chunk {idx}: {start:.1f}s -> {end:.1f}s (file to {extract_end:.1f}s)")
if end >= total_dur:
break
start = end - OVERLAP_SEC/2 # start next chunk with half overlap
idx += 1
return chunks
def format_srt_time(seconds: float):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def send_chunk_for_transcription(args):
chunk_path, true_start, true_end, chunk_idx = args
try:
with open(chunk_path, 'rb') as f:
files = {'file': (chunk_path.name, f, 'audio/wav')}
resp = requests.post(WORKER_URL, files=files, timeout=1200)
resp.raise_for_status()
data = resp.json()
# Filter to only keep segments within true window (dedupe overlap)
filtered = []
for seg in data['segments']:
abs_start = true_start + seg['start']
# keep if it starts before the true_end (allow small spill)
if abs_start < true_end + OVERLAP_SEC/2:
# if it's in overlap zone of NEXT chunk, only keep first half
if abs_start < true_end - OVERLAP_SEC/2 or chunk_idx == 0:
filtered.append(seg)
elif seg['start'] < (true_end - true_start) - OVERLAP_SEC/2 + 1.0:
filtered.append(seg)
return chunk_idx, true_start, filtered, None
except Exception as e:
return chunk_idx, true_start, None, str(e)
def transcribe_to_srt_remote(video_path: str, force: bool = False):
video_path_obj = Path(video_path)
srt_path = video_path_obj.with_suffix(".srt")
temp_dir = video_path_obj.parent / f"temp_chunks_{video_path_obj.stem}"
if srt_path.exists() and not force:
print(f"❌ SRT already exists: {srt_path}")
return
if srt_path.exists():
srt_path.unlink()
audio_chunks = extract_audio_and_chunk_smart(str(video_path_obj), temp_dir)
jobs = [(path, s, e, idx) for idx, (path, s, e) in enumerate(audio_chunks)]
results = []
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
futures = {executor.submit(send_chunk_for_transcription, job): job for job in jobs}
for future in as_completed(futures):
idx, offset, segments, error = future.result()
if error:
print(f"❌ Chunk {idx} failed: {error}")
else:
print(f"✅ Chunk {idx} done: {len(segments)} segs (offset {offset:.1f}s)")
results.append((idx, offset, segments))
finally:
executor.shutdown(wait=True)
results.sort(key=lambda x: x[0])
print("📝 Writing final SRT with smart overlap dedupe...")
global_idx = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for _, time_offset, segments in results:
for seg in segments:
actual_start = seg['start'] + time_offset
actual_end = seg['end'] + time_offset
srt_file.write(f"{global_idx}\n{format_srt_time(actual_start)} --> {format_srt_time(actual_end)}\n{seg['text'].strip()}\n\n")
global_idx += 1
print(f"\n✅ SRT saved: {srt_path} - {global_idx-1} lines")
if temp_dir.exists():
shutil.rmtree(temp_dir)
if __name__ == "__main__":
target_video = "download/videos/2813112936/2813112936.mp4"
transcribe_to_srt_remote(target_video, force=True)
+56 -56
View File
@@ -3,117 +3,117 @@ import os
import shutil
import subprocess
from pathlib import Path
import wave
from faster_whisper import WhisperModel
from faster_whisper.utils import format_timestamp
# Prevent OpenMP thread conflicts from crashing the script
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# 5 minutes per chunk (300 seconds) keeps RAM usage low and stable
CHUNK_DURATION_SEC = 300
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list:
"""Extracts and splits audio into 5-minute chunks using a single FFmpeg pass."""
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list[tuple[Path, float]]:
"""Returns list of (chunk_path, start_offset_seconds)"""
print("🚀 Extracting and chunking audio with FFmpeg...")
output_dir.mkdir(parents=True, exist_ok=True)
# Segment format output: chunk_000.wav, chunk_001.wav, etc.
chunk_pattern = str(output_dir / "chunk_%03d.wav")
command = [
"ffmpeg", "-y", "-i", video_path,
"-vn", "-ac", "1", "-ar", "16000",
"-acodec", "pcm_s16le", "-sn", "-map_chapters", "-1",
"-acodec", "pcm_s16le",
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
"-reset_timestamps", "0", # keep each chunk starting at 0
chunk_pattern
]
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"❌ FFmpeg Error Output:\n{result.stderr}")
raise RuntimeError("FFmpeg extraction and chunking failed.")
# Return sorted list of generated chunk files
return sorted(list(output_dir.glob("chunk_*.wav")))
print(f"❌ FFmpeg Error:\n{result.stderr}")
raise RuntimeError("FFmpeg failed")
chunks = sorted(output_dir.glob("chunk_*.wav"))
# FIX: Calculate real offset from actual file durations, not idx*300
offsets = []
current_offset = 0.0
for chunk_path in chunks:
offsets.append((chunk_path, current_offset))
with wave.open(str(chunk_path), 'rb') as wf:
duration = wf.getnframes() / wf.getframerate()
current_offset += duration
return offsets
def format_srt_time(seconds: float):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def transcribe_to_srt(video_path: str, force: bool = False):
if not hasattr(transcribe_to_srt, "model"):
print("🔧 Initializing C++ Engine...")
transcribe_to_srt.model = WhisperModel(
"base",
device="cpu",
compute_type="int8",
cpu_threads=0, # Let CTranslate2 auto-detect safe core counts
num_workers=1
"distil-small.en",
device="cpu",
compute_type="int8",
cpu_threads=4, # 4 = physical cores on E3-1280 v5 = fastest
num_workers=1
)
print("✅ C++ Model loaded successfully.")
print("✅ Model loaded.")
video_path_obj = Path(video_path)
srt_path = video_path_obj.with_suffix(".srt")
temp_dir = video_path_obj.parent / f"temp_chunks_{video_path_obj.stem}"
if srt_path.exists() and not force:
print(f"❌ SRT already exists: {srt_path}")
return
if srt_path.exists():
if not force:
print(f"❌ Error: Transcription file already exists: {srt_path}")
return
else:
srt_path.unlink()
srt_path.unlink()
try:
# Step 1: Split audio into bite-sized pieces
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
if not audio_chunks:
print("❌ Error: No audio chunks were generated.")
return
print(f"📦 {len(audio_chunks)} chunks.")
print("🎙 Transcribing...")
print(f"📦 Successfully split audio into {len(audio_chunks)} chunks.")
print("🎙️ Starting safe chunk-by-chunk transcription...")
global_segment_index = 1
global_idx = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for chunk_idx, chunk_path in enumerate(audio_chunks):
# Calculate the time offset for the current chunk
time_offset = chunk_idx * CHUNK_DURATION_SEC
print(f"\n⏳ Processing chunk {chunk_idx + 1}/{len(audio_chunks)} ({chunk_path.name})...")
for chunk_idx, (chunk_path, time_offset) in enumerate(audio_chunks):
print(f"\n⏳ Chunk {chunk_idx+1}/{len(audio_chunks)} offset={time_offset:.1f}s")
segments_generator, info = transcribe_to_srt.model.transcribe(
segments, info = transcribe_to_srt.model.transcribe(
str(chunk_path),
language="en",
beam_size=1,
vad_filter=True,
temperature=0.0
vad_parameters=dict(min_silence_duration_ms=500),
condition_on_previous_text=False # important for chunked mode
)
# Consume chunk generator and shift timestamps instantly
for segment in segments_generator:
# Shift timestamps relative to the original video timeline
actual_start = segment.start + time_offset
actual_end = segment.end + time_offset
for seg in segments:
actual_start = seg.start + time_offset
actual_end = seg.end + time_offset
start_str = format_timestamp(actual_start, always_include_hours=True)
end_str = format_timestamp(actual_end, always_include_hours=True)
# Use comma for SRT, not dot
srt_file.write(f"{global_idx}\n{format_srt_time(actual_start)} --> {format_srt_time(actual_end)}\n{seg.text.strip()}\n\n")
global_idx += 1
srt_file.write(f"{global_segment_index}\n{start_str} --> {end_str}\n{segment.text.strip()}\n\n")
global_segment_index += 1
# Free up space as we go by deleting the processed chunk
chunk_path.unlink()
print(f"\n✅ All chunks combined! SRT subtitle file saved in: {srt_path}")
print(f"\n✅ SRT saved: {srt_path}")
except Exception as e:
print(f"\n❌ Execution Error: {e}")
print(f"\n❌ Error: {e}")
import traceback; traceback.print_exc()
finally:
# Clean up the temporary folder entirely
if temp_dir.exists():
shutil.rmtree(temp_dir)
if __name__ == "__main__":
target_video = "download/videos/2813112936/2813112936.mp4"
if not os.path.exists(target_video):
print(f"❌ System Error: Target video file does not exist at path: {target_video}")
print(f"❌ Not found: {target_video}")
else:
transcribe_to_srt(target_video, force=True)
transcribe_to_srt(target_video, force=True)
+350
View File
@@ -0,0 +1,350 @@
import re
import cv2
import numpy as np
import argparse
import json
from pathlib import Path
from moviepy import VideoFileClip
# ---- CONFIG ----
DEFAULT_KEYWORDS = [
# Pure Hype - the #1 highlight signal
"pog", "poggers", "pogchamp", "let's go", "lets go", "huge", "insane", "crazy",
"unbelievable", "no way", "way too good", "oh my god", "omg", "wow", "holy",
"holy shit", "holy fuck", "what the fuck", "wtf", "lfg", "clip that", "clip it",
"someone clip", "chat clip", "world record", "pb", "personal best",
# Chat interaction = something happened
"chat", "look at chat", "chat is going crazy", "we are so back", "we're so back",
"it's over", "ggs", "gg", "not like this", "like this", "AYAYA", "KEKW",
"monka", "pepega", "copium",
# Gaming clutch moments
"clutch", "ace", "1v4", "1v3", "1v2", "quadra", "penta", "pentakill",
"triple kill", "rampage", "unstoppable", "godlike", "wipe", "team wipe",
"no scope", "collat", "one shot", "one tap", "200 pumped", "knocked", "cracked",
# Reaction / laugh / fail
"bro", "dude", "what is happening", "what just happened", "there is no way",
"you're kidding", "stop", "no no no", "yes yes yes", "i can't", "i cant believe",
"this is insane", "this guy", "look at this", "wait wait wait"
]
# You can add your own: --keywords "goal, fight, knock out"
import subprocess
import tempfile
import os
from scipy.io import wavfile
def get_av_scores(video_path, sample_fps=2):
print(f"-> Opening: {video_path}", flush=True)
if not os.path.exists(video_path):
raise FileNotFoundError(video_path)
with VideoFileClip(video_path) as clip:
duration = clip.duration
fps = clip.fps or 30
has_audio = clip.audio is not None
print(f" duration={duration:.2f}s fps={fps} has_audio={has_audio}", flush=True)
n_secs = int(duration)
# ---- 1. AUDIO (streaming, constant RAM) ----
print(f"[1/3] Audio energy...", flush=True)
audio_per_sec = np.zeros(n_secs)
if has_audio:
tmp_wav = tempfile.mktemp(suffix=".wav")
try:
print(f" extracting mono 16k wav with ffmpeg...", flush=True)
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-ar", "16000",
"-ac", "1",
"-c:a", "pcm_s16le",
tmp_wav
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
if result.returncode!= 0:
print(f" ffmpeg failed: {result.stderr.decode()[:1000]}")
else:
wav_size = os.path.getsize(tmp_wav)
print(f" wav size: {wav_size} bytes, computing RMS streaming...", flush=True)
# STREAM instead of loading all at once
import wave
with wave.open(tmp_wav, 'rb') as wf:
sr = wf.getframerate() # should be 16000
n_frames = wf.getnframes()
# sr frames = 1 second
for i in range(n_secs):
if wf.tell() >= n_frames:
break
# read 1 second
frames = wf.readframes(sr)
if not frames:
break
# int16 -> float without extra copy
chunk = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
if len(chunk) > 0:
audio_per_sec[i] = np.sqrt(np.mean(chunk**2))
if i % 300 == 0: # log every 5 min
print(f" audio {i}/{n_secs}s", flush=True)
if audio_per_sec.max() > 0:
audio_per_sec = (audio_per_sec - audio_per_sec.min()) / (audio_per_sec.max() - audio_per_sec.min() + 1e-6)
print(f" audio done", flush=True)
except Exception as e:
print(f" audio error: {e}")
import traceback; traceback.print_exc()
finally:
if os.path.exists(tmp_wav):
os.remove(tmp_wav)
else:
print(" no audio track", flush=True)
# ---- 2. MOTION ----
print(f"[2/3] Motion analysis sample_fps={sample_fps}...", flush=True)
cap = cv2.VideoCapture(video_path)
motion_scores = []
prev_gray = None
frame_interval = int(fps / sample_fps) if fps > 0 else 15
num_samples = int(duration * sample_fps)
for i in range(num_samples):
cap.set(cv2.CAP_PROP_POS_FRAMES, i * frame_interval)
ret, frame = cap.read()
if not ret:
continue
gray = cv2.cvtColor(cv2.resize(frame, (320, 180)), cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
motion_scores.append(np.mean(cv2.absdiff(gray, prev_gray)) / 255.0)
else:
motion_scores.append(0)
prev_gray = gray
if i % 50 == 0:
print(f" motion {i}/{num_samples}", flush=True)
cap.release()
if len(motion_scores) > 1:
x = np.linspace(0, duration-1, len(motion_scores))
motion_per_sec = np.interp(np.arange(n_secs), x, motion_scores)
if motion_per_sec.max() > 0:
motion_per_sec = (motion_per_sec - motion_per_sec.min()) / (motion_per_sec.max() - motion_per_sec.min() + 1e-6)
else:
motion_per_sec = np.zeros(n_secs)
combined_av = 0.6 * audio_per_sec + 0.4 * motion_per_sec
return combined_av, duration
def srt_time_to_seconds(time_str):
# 00:01:23,456 or 00:01:23.456
time_str = time_str.strip().replace('.', ',')
h, m, s_ms = time_str.split(':')
s, ms = s_ms.split(',')
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
def load_text_from_srt(srt_path):
print(f"[3/3] Loading transcript from SRT: {srt_path}")
if not Path(srt_path).exists():
raise FileNotFoundError(f"SRT not found: {srt_path}")
# utf-8-sig strips BOM that Whisper sometimes adds
with open(srt_path, 'r', encoding='utf-8-sig') as f:
content = f.read().strip()
if not content:
raise ValueError(f"SRT file is empty: {srt_path}")
# Split by blank line - works for \n\n and \r\n\r\n
blocks = re.split(r'\r?\n\r?\n', content)
segments = []
# Pattern inside each block only
time_pattern = re.compile(r'(\d{2}:\d{2}:\d{2}[,\.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,\.]\d{3})')
for block in blocks:
block = block.strip()
if not block:
continue
lines = block.splitlines()
if len(lines) < 2:
continue
# Find time line (line 0 or 1 - index may be missing)
time_match = None
text_start_idx = 0
for idx, line in enumerate(lines[:3]): # check first 3 lines for time
m = time_pattern.search(line)
if m:
time_match = m
text_start_idx = idx + 1
break
if not time_match:
continue
start_str, end_str = time_match.groups()
text = ' '.join(lines[text_start_idx:]).strip()
# Clean Whisper / Twitch artifacts
text = re.sub(r'<[^>]+>', '', text) # remove <i>, <font> etc
text = text.replace('\n', ' ').strip()
if text:
segments.append({
"start": srt_time_to_seconds(start_str),
"end": srt_time_to_seconds(end_str),
"text": text
})
print(f"Loaded {len(segments)} segments from SRT")
if len(segments) == 0:
print(f"WARNING: Parsed 0 segments. First 500 chars of file:\n{content[:500]}")
return segments
def score_keywords(segments, duration, keywords):
"""Returns per-second keyword boost score + keyword hits"""
keyword_score = np.zeros(int(duration))
hits = []
# Build regex with word boundaries for each keyword
# \bace\b won't match "place", \bgg\b won't match "ggs"
patterns = []
for kw in keywords:
kw = kw.strip()
if not kw:
continue
# \b at start/end, but handle multi-word phrases
# re.escape handles "let's go", "1v4", etc
pat = re.compile(r'\b' + re.escape(kw.lower()) + r'\b', re.IGNORECASE)
patterns.append((kw.lower(), pat))
for seg in segments:
text = seg['text']
text_lower = text.lower()
for kw_lower, pat in patterns:
if pat.search(text_lower):
start = int(max(0, seg['start'] - 1))
end = int(min(duration-1, seg['end'] + 2))
keyword_score[start:end+1] += 1.0
hits.append({
"timestamp": round(seg['start'], 2),
"end": round(seg['end'], 2),
"keyword": kw_lower,
"text": text.strip()
})
break
if keyword_score.max() > 0:
keyword_score = keyword_score / keyword_score.max()
return keyword_score, hits
def find_highlights(final_scores, duration, top_n=5, clip_len=8, min_gap=12):
candidates = []
for i in range(len(final_scores) - clip_len):
candidates.append((np.mean(final_scores[i:i+clip_len]), i))
candidates.sort(reverse=True, key=lambda x: x[0])
picked = []
for score, start in candidates:
if all(abs(start - p[1]) >= min_gap for p in picked):
picked.append((score, start))
if len(picked) >= top_n:
break
picked.sort(key=lambda x: x[1])
return [(s, e, sc) for sc, s in [(sc, st) for sc, st in picked] for e in [s+clip_len]]
def format_timestamp(seconds):
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
return f"{h:02d}:{m:02d}:{s:02d}" if h > 0 else f"{m:02d}:{s:02d}"
def highlights(video_path, keywords=None, top_n=5, clip_len=8, min_gap=12):
if keywords is None:
keywords = DEFAULT_KEYWORDS
av_score, duration = get_av_scores(video_path, sample_fps=0.5)
segments = load_text_from_srt(video_path.replace('.mp4', '.srt'))
kw_score, kw_hits = score_keywords(segments, duration, keywords)
final_score = av_score + (kw_score * 1.5)
final_score = (final_score - final_score.min()) / (final_score.max() - final_score.min() + 1e-6)
final_score = np.convolve(final_score, np.ones(3)/3, mode='same')
highlights = find_highlights(final_score, duration, top_n=top_n, clip_len=clip_len)
return highlights, kw_hits
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find highlights with Whisper keywords + timestamps")
parser.add_argument("video", help="Input video file")
parser.add_argument("--keywords", type=str, default=",".join(DEFAULT_KEYWORDS), help="Comma-separated keywords to hunt for")
parser.add_argument("--model", type=str, default="base", help="Whisper model: tiny, base, small, medium")
parser.add_argument("--top", type=int, default=5)
parser.add_argument("--len", type=int, default=30, help="Highlight length in seconds")
parser.add_argument("--no-cut", action="store_true", help="Only print timestamps, don't cut video files")
args = parser.parse_args()
keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
av_score, duration = get_av_scores(args.video, sample_fps=0.5)
segments = load_text_from_srt(args.video.replace('.mp4', '.srt'))
kw_score, kw_hits = score_keywords(segments, duration, keywords)
# FINAL SCORE: AV + keyword boost (keyword is huge if found)
final_score = av_score + (kw_score * 1.5)
final_score = (final_score - final_score.min()) / (final_score.max() - final_score.min() + 1e-6)
final_score = np.convolve(final_score, np.ones(3)/3, mode='same') # smooth
highlights = find_highlights(final_score, duration, top_n=args.top, clip_len=args.len)
# --- OUTPUT ---
print("\n" + "="*60)
print("KEYWORD HITS:")
for h in kw_hits:
print(f" [{format_timestamp(h['timestamp'])}] ({h['keyword']}) -> {h['text']}")
print("\n" + "="*60)
print("FINAL HIGHLIGHTS:")
output_data = []
for i, (start, end, score) in enumerate(highlights, 1):
# find transcript in that window
texts_in_window = [s['text'].strip() for s in segments if not (s['end'] < start or s['start'] > end)]
transcript = " ".join(texts_in_window)[:120]
print(f"{i}. {format_timestamp(start)} - {format_timestamp(end)} (score {score:.2f}) | {transcript}")
output_data.append({
"id": i,
"start_sec": float(start),
"end_sec": float(end),
"start_timestamp": format_timestamp(start),
"end_timestamp": format_timestamp(end),
"score": float(score),
"transcript": transcript
})
# Save timestamps to JSON
with open("highlights.json", "w") as f:
json.dump({"video": args.video, "keywords": keywords, "highlights": output_data, "keyword_hits": kw_hits}, f, indent=2)
print(f"\nTimestamps saved to highlights.json")
# Cut videos if requested
#if not args.no_cut:
if args.no_cut:
print("\nCutting clips...")
Path("highlights").mkdir(exist_ok=True)
clip = VideoFileClip(args.video)
for h in output_data:
sub = clip.subclip(h['start_sec'], h['end_sec'])
out = f"highlights/highlight_{h['id']}_{h['start_timestamp'].replace(':','-')}.mp4"
sub.write_videofile(out, codec="libx264", audio_codec="aac", logger=None)
clip.close()
print("Done ->./highlights/")