created remote transcriber
This commit is contained in:
1 parent
856ab439f7
commit
38e0cc6764
10 files changed
+718
-68
No files matched your search
+3
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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
@@ -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)
|
||||
@@ -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/")
|
||||
Reference in new issue
Block a user