created remote transcriber
This commit is contained in:
1 parent
856ab439f7
commit
38e0cc6764
10 files changed
+718
-68
No files matched your search
@@ -15,7 +15,7 @@ def remove_files():
|
|||||||
for data in datas:
|
for data in datas:
|
||||||
|
|
||||||
if data.clip_is is False:
|
if data.clip_is is False:
|
||||||
continue
|
#continue
|
||||||
video_path = f"download/videos/{data.id}/{data.id}.mp4"
|
video_path = f"download/videos/{data.id}/{data.id}.mp4"
|
||||||
else:
|
else:
|
||||||
video_path = f"download/clips/{data.id}/{data.id}.mp4"
|
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")
|
video_chat_over = ""#video_path.replace(".mp4", "_over_with_chat.mp4")
|
||||||
mask_path = ""#video_path.replace(".mp4", "_temp_chat_mask.mp4")
|
mask_path = ""#video_path.replace(".mp4", "_temp_chat_mask.mp4")
|
||||||
|
|
||||||
shorts_gaussian_9_16 = video_path.replace(".mp4", "_gaussian_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")
|
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]
|
files = [temp_chat, temp_with_chat, video_chatSS, video_chat_over, mask_path, shorts_gaussian_9_16, shorts_9_16, srt_file, video_chat]
|
||||||
|
|||||||
+3
-1
@@ -20,6 +20,7 @@ from .config import CHANNEL_NAME, TZ, DB
|
|||||||
from .hash_tags import get_top_nouns
|
from .hash_tags import get_top_nouns
|
||||||
from .process_uploads import process_uploads
|
from .process_uploads import process_uploads
|
||||||
from .transcribe_video import transcribe_to_srt
|
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
|
from .utils import get_chapters, get_video_id_from_chat, write_csv, run_command
|
||||||
|
|
||||||
# 4. Expose Subpackages
|
# 4. Expose Subpackages
|
||||||
@@ -36,7 +37,8 @@ __all__ = [
|
|||||||
"DB",
|
"DB",
|
||||||
"get_top_nouns",
|
"get_top_nouns",
|
||||||
"process_uploads",
|
"process_uploads",
|
||||||
"transcribe_to_srt",
|
"transcribe_to_srt",
|
||||||
|
"transcribe_to_srt_remote",
|
||||||
"write_csv",
|
"write_csv",
|
||||||
"run_command",
|
"run_command",
|
||||||
"get_chapters",
|
"get_chapters",
|
||||||
|
|||||||
+34
-5
@@ -3,7 +3,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
import package.video_editing.build_shorts as yt_shorts
|
import package.video_editing.build_shorts as yt_shorts
|
||||||
import package.transcribe_video as transcribe_video
|
import package.transcribe_video as transcribe_video
|
||||||
|
import package.transcribe_client as transcribe_client
|
||||||
import package.twitch.chat_vod as chat_vod
|
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 DB as DB
|
||||||
from package.config import TZ as TZ
|
from package.config import TZ as TZ
|
||||||
|
|
||||||
@@ -22,19 +24,20 @@ def build_chat_video():
|
|||||||
print("====================================================")
|
print("====================================================")
|
||||||
|
|
||||||
def build_transcribe():
|
def build_transcribe():
|
||||||
unuploadeds = DB.get_unuploaded()
|
downloads = DB.get_downloaded()
|
||||||
|
|
||||||
for unuploaded in unuploadeds:
|
for download in downloads:
|
||||||
if unuploaded.clip_is:
|
if download.clip_is:
|
||||||
target_dir = f"download/clips"
|
target_dir = f"download/clips"
|
||||||
else:
|
else:
|
||||||
target_dir = f"download/videos"
|
target_dir = f"download/videos"
|
||||||
|
|
||||||
print("====================================================")
|
print("====================================================")
|
||||||
print(f"🚀 Transcribe: {unuploaded.title}")
|
print(f"🚀 Transcribe: {download.title}")
|
||||||
print("====================================================")
|
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("====================================================")
|
||||||
print(f"✅ Processing: Transcribing Done.")
|
print(f"✅ Processing: Transcribing Done.")
|
||||||
@@ -65,6 +68,32 @@ def build_shorts():
|
|||||||
print(f"✅ Processing: Clip to Youtube Short Done.")
|
print(f"✅ Processing: Clip to Youtube Short Done.")
|
||||||
print("====================================================")
|
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():
|
def build_videos():
|
||||||
build_shorts()
|
build_shorts()
|
||||||
build_transcribe()
|
build_transcribe()
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ class TwitchVideo(Base):
|
|||||||
uploaded_yt = Column(Boolean, nullable=False, default=False)
|
uploaded_yt = Column(Boolean, nullable=False, default=False)
|
||||||
uploaded_yt_chats = 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_yt_shorts = Column(Boolean, nullable=False, default=False)
|
||||||
|
uploaded_tiktok = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
# This now actually points to twitch_videos.id
|
# This now actually points to twitch_videos.id
|
||||||
related_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=True)
|
related_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=True)
|
||||||
|
|
||||||
# Relationship back to YoutubeInfo (uselist=True enforces a 1-to-many structure)
|
# Relationship back to YoutubeInfo (uselist=True enforces a 1-to-many structure)
|
||||||
youtube_info = relationship("YoutubeInfo", back_populates="twitch_video", uselist=True)
|
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")
|
chapters = relationship("Chapter", back_populates="twitch_video", cascade="all, delete-orphan")
|
||||||
|
|
||||||
# 3. Define the data table
|
# 3. Define the data table
|
||||||
@@ -61,6 +63,17 @@ class YoutubeInfo(Base):
|
|||||||
# Relationship linking back to the twitch video record
|
# Relationship linking back to the twitch video record
|
||||||
twitch_video = relationship("TwitchVideo", back_populates="youtube_info")
|
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):
|
class Chapter(Base):
|
||||||
__tablename__ = "chapters"
|
__tablename__ = "chapters"
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ def process_videos(video_type: bool = True, limit: int = 22):
|
|||||||
meta_data: Data = DB.get_data()[0]
|
meta_data: Data = DB.get_data()[0]
|
||||||
|
|
||||||
current_time = datetime.now(TZ)
|
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:
|
if current_time > release_time:
|
||||||
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
|
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)
|
youtube_info.yt_short_date = datetime.now(TZ)
|
||||||
|
|
||||||
DB.insert(youtube_info)
|
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
|
# Update global release pointer once at end
|
||||||
if video_type:
|
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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import wave
|
||||||
from faster_whisper import WhisperModel
|
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"
|
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
||||||
|
|
||||||
# 5 minutes per chunk (300 seconds) keeps RAM usage low and stable
|
|
||||||
CHUNK_DURATION_SEC = 300
|
CHUNK_DURATION_SEC = 300
|
||||||
|
|
||||||
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list:
|
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list[tuple[Path, float]]:
|
||||||
"""Extracts and splits audio into 5-minute chunks using a single FFmpeg pass."""
|
"""Returns list of (chunk_path, start_offset_seconds)"""
|
||||||
print("🚀 Extracting and chunking audio with FFmpeg...")
|
print("🚀 Extracting and chunking audio with FFmpeg...")
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
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")
|
chunk_pattern = str(output_dir / "chunk_%03d.wav")
|
||||||
|
|
||||||
command = [
|
command = [
|
||||||
"ffmpeg", "-y", "-i", video_path,
|
"ffmpeg", "-y", "-i", video_path,
|
||||||
"-vn", "-ac", "1", "-ar", "16000",
|
"-vn", "-ac", "1", "-ar", "16000",
|
||||||
"-acodec", "pcm_s16le", "-sn", "-map_chapters", "-1",
|
"-acodec", "pcm_s16le",
|
||||||
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
|
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
|
||||||
|
"-reset_timestamps", "0", # keep each chunk starting at 0
|
||||||
chunk_pattern
|
chunk_pattern
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"❌ FFmpeg Error Output:\n{result.stderr}")
|
print(f"❌ FFmpeg Error:\n{result.stderr}")
|
||||||
raise RuntimeError("FFmpeg extraction and chunking failed.")
|
raise RuntimeError("FFmpeg failed")
|
||||||
|
|
||||||
# Return sorted list of generated chunk files
|
|
||||||
return sorted(list(output_dir.glob("chunk_*.wav")))
|
|
||||||
|
|
||||||
|
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):
|
def transcribe_to_srt(video_path: str, force: bool = False):
|
||||||
if not hasattr(transcribe_to_srt, "model"):
|
if not hasattr(transcribe_to_srt, "model"):
|
||||||
print("🔧 Initializing C++ Engine...")
|
print("🔧 Initializing C++ Engine...")
|
||||||
transcribe_to_srt.model = WhisperModel(
|
transcribe_to_srt.model = WhisperModel(
|
||||||
"base",
|
"distil-small.en",
|
||||||
device="cpu",
|
device="cpu",
|
||||||
compute_type="int8",
|
compute_type="int8",
|
||||||
cpu_threads=0, # Let CTranslate2 auto-detect safe core counts
|
cpu_threads=4, # 4 = physical cores on E3-1280 v5 = fastest
|
||||||
num_workers=1
|
num_workers=1
|
||||||
)
|
)
|
||||||
print("✅ C++ Model loaded successfully.")
|
print("✅ Model loaded.")
|
||||||
|
|
||||||
|
|
||||||
video_path_obj = Path(video_path)
|
video_path_obj = Path(video_path)
|
||||||
srt_path = video_path_obj.with_suffix(".srt")
|
srt_path = video_path_obj.with_suffix(".srt")
|
||||||
temp_dir = video_path_obj.parent / f"temp_chunks_{video_path_obj.stem}"
|
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 srt_path.exists():
|
||||||
if not force:
|
srt_path.unlink()
|
||||||
print(f"❌ Error: Transcription file already exists: {srt_path}")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
srt_path.unlink()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Split audio into bite-sized pieces
|
|
||||||
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
|
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
|
||||||
if not audio_chunks:
|
print(f"📦 {len(audio_chunks)} chunks.")
|
||||||
print("❌ Error: No audio chunks were generated.")
|
print("🎙 Transcribing...")
|
||||||
return
|
|
||||||
|
|
||||||
print(f"📦 Successfully split audio into {len(audio_chunks)} chunks.")
|
global_idx = 1
|
||||||
print("🎙️ Starting safe chunk-by-chunk transcription...")
|
|
||||||
|
|
||||||
global_segment_index = 1
|
|
||||||
|
|
||||||
with open(srt_path, "w", encoding="utf-8") as srt_file:
|
with open(srt_path, "w", encoding="utf-8") as srt_file:
|
||||||
for chunk_idx, chunk_path in enumerate(audio_chunks):
|
for chunk_idx, (chunk_path, time_offset) in enumerate(audio_chunks):
|
||||||
# Calculate the time offset for the current chunk
|
print(f"\n⏳ Chunk {chunk_idx+1}/{len(audio_chunks)} offset={time_offset:.1f}s")
|
||||||
time_offset = chunk_idx * CHUNK_DURATION_SEC
|
|
||||||
print(f"\n⏳ Processing chunk {chunk_idx + 1}/{len(audio_chunks)} ({chunk_path.name})...")
|
|
||||||
|
|
||||||
segments_generator, info = transcribe_to_srt.model.transcribe(
|
segments, info = transcribe_to_srt.model.transcribe(
|
||||||
str(chunk_path),
|
str(chunk_path),
|
||||||
|
language="en",
|
||||||
beam_size=1,
|
beam_size=1,
|
||||||
vad_filter=True,
|
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 seg in segments:
|
||||||
for segment in segments_generator:
|
actual_start = seg.start + time_offset
|
||||||
# Shift timestamps relative to the original video timeline
|
actual_end = seg.end + time_offset
|
||||||
actual_start = segment.start + time_offset
|
|
||||||
actual_end = segment.end + time_offset
|
|
||||||
|
|
||||||
start_str = format_timestamp(actual_start, always_include_hours=True)
|
# Use comma for SRT, not dot
|
||||||
end_str = format_timestamp(actual_end, always_include_hours=True)
|
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()
|
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:
|
except Exception as e:
|
||||||
print(f"\n❌ Execution Error: {e}")
|
print(f"\n❌ Error: {e}")
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
finally:
|
finally:
|
||||||
# Clean up the temporary folder entirely
|
|
||||||
if temp_dir.exists():
|
if temp_dir.exists():
|
||||||
shutil.rmtree(temp_dir)
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
target_video = "download/videos/2813112936/2813112936.mp4"
|
target_video = "download/videos/2813112936/2813112936.mp4"
|
||||||
if not os.path.exists(target_video):
|
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:
|
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