diff --git a/helper_files/remove_uneed_files.py b/helper_files/remove_uneed_files.py index 7ba6a3c..ca5652a 100644 --- a/helper_files/remove_uneed_files.py +++ b/helper_files/remove_uneed_files.py @@ -15,7 +15,7 @@ def remove_files(): for data in datas: if data.clip_is is False: - continue + #continue video_path = f"download/videos/{data.id}/{data.id}.mp4" else: video_path = f"download/clips/{data.id}/{data.id}.mp4" @@ -27,10 +27,10 @@ def remove_files(): video_chat_over = ""#video_path.replace(".mp4", "_over_with_chat.mp4") mask_path = ""#video_path.replace(".mp4", "_temp_chat_mask.mp4") - shorts_gaussian_9_16 = video_path.replace(".mp4", "_gaussian_9_16.mp4") - shorts_9_16 = video_path.replace(".mp4", "_9_16.mp4") + shorts_gaussian_9_16 = ""#video_path.replace(".mp4", "_gaussian_9_16.mp4") + shorts_9_16 = ""#video_path.replace(".mp4", "_9_16.mp4") - srt_file = ""#video_path.replace(".mp4", ".srt") + srt_file = video_path.replace(".mp4", ".srt") files = [temp_chat, temp_with_chat, video_chatSS, video_chat_over, mask_path, shorts_gaussian_9_16, shorts_9_16, srt_file, video_chat] diff --git a/package/__init__.py b/package/__init__.py index 54b1a3a..3c05722 100644 --- a/package/__init__.py +++ b/package/__init__.py @@ -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", diff --git a/package/build_videos.py b/package/build_videos.py index 7fe50ee..324e553 100644 --- a/package/build_videos.py +++ b/package/build_videos.py @@ -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() diff --git a/package/database/database.py b/package/database/database.py index f45888b..17a6223 100644 --- a/package/database/database.py +++ b/package/database/database.py @@ -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" diff --git a/package/process_uploads.py b/package/process_uploads.py index 22194d4..916dd7e 100644 --- a/package/process_uploads.py +++ b/package/process_uploads.py @@ -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: diff --git a/package/tiktok/__init__.py b/package/tiktok/__init__.py new file mode 100644 index 0000000..733b2dc --- /dev/null +++ b/package/tiktok/__init__.py @@ -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" +] \ No newline at end of file diff --git a/package/tiktok/uploader.py b/package/tiktok/uploader.py new file mode 100644 index 0000000..a4ae818 --- /dev/null +++ b/package/tiktok/uploader.py @@ -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") \ No newline at end of file diff --git a/package/transcribe_client.py b/package/transcribe_client.py new file mode 100644 index 0000000..b93c5c8 --- /dev/null +++ b/package/transcribe_client.py @@ -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) \ No newline at end of file diff --git a/package/transcribe_video.py b/package/transcribe_video.py index 858d149..aada805 100755 --- a/package/transcribe_video.py +++ b/package/transcribe_video.py @@ -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) \ No newline at end of file diff --git a/package/video_editing/highlights.py b/package/video_editing/highlights.py new file mode 100644 index 0000000..025c7f9 --- /dev/null +++ b/package/video_editing/highlights.py @@ -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 , 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/") \ No newline at end of file