From 33d78759fef26a4cddb26e3a67e52189adc55c24 Mon Sep 17 00:00:00 2001 From: SumGuyV5 Date: Tue, 4 Aug 2026 02:34:16 +0000 Subject: [PATCH] putting it all togeather --- build_videos.py | 16 ++- database.py | 2 +- main.py | 17 +++ transcribe_video.py | 153 ++++++++++++++++++-------- twitch_chat_vod.py | 42 ++++--- twitch_download_videos.py | 8 +- youtube_short.py | 223 ++++++++++++++++++++------------------ 7 files changed, 282 insertions(+), 179 deletions(-) create mode 100644 main.py diff --git a/build_videos.py b/build_videos.py index f0456b8..0c60f96 100644 --- a/build_videos.py +++ b/build_videos.py @@ -33,7 +33,7 @@ def build_chat_video(): print(f"๐Ÿš€ Transcribe: {title}") print("====================================================") - twitch_chat_vod.combine_twitch_vod_and_chat(f"download/videos/{id}/{id}.mp4") + twitch_chat_vod.combine_twitch_vod_and_chat(f"download/videos/{id}/{id}.mp4", "side-by-side", True) def build_transcribe(): @@ -68,7 +68,7 @@ def build_transcribe(): print(f"๐Ÿš€ Transcribe: {title}") print("====================================================") - transcribe_video.transcribe_to_srt(f"{target_dir}/{id}/{id}.mp4") + transcribe_video.transcribe_to_srt(f"{target_dir}/{id}/{id}.mp4", True) def build_shorts(): @@ -102,10 +102,14 @@ def build_shorts(): print(f"๐Ÿš€ Processing: Clip to Youtube Short {title}") print("====================================================") - youtube_short.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {creator_name}.", True) + youtube_short.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {creator_name}.", True) -if __name__ =="__main__": +def main(): + global DB DB = database.Database() - #build_shorts() + build_shorts() build_transcribe() - build_chat_video() \ No newline at end of file + build_chat_video() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/database.py b/database.py index 83f10f7..7ae7f4d 100755 --- a/database.py +++ b/database.py @@ -91,7 +91,7 @@ class Database: def get_unuploaded_chats(self) -> list[Any]: """Retrieve all rows that were download but not uploaded_yt_chats""" - return self.__get_unuploaded("uploaded_yt_chats", "AND clips_is = 0") + return self.__get_unuploaded("uploaded_yt_chats", "AND clip_is = 0") def get_unuploaded(self) -> list[Any]: """Retrieve all rows that were download but not uploaded_yt""" diff --git a/main.py b/main.py new file mode 100644 index 0000000..2cdfdbe --- /dev/null +++ b/main.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import asyncio + +import twitch_video_info +import twitch_download_videos + +import build_videos + +if __name__ == "__main__": + # Get Twitch Video Info + asyncio.run(twitch_video_info.main()) + + # Download Twitch Videos + twitch_download_videos.main() + + #Build + build_videos.main() diff --git a/transcribe_video.py b/transcribe_video.py index 1af7960..5a5b6f5 100755 --- a/transcribe_video.py +++ b/transcribe_video.py @@ -1,62 +1,119 @@ #!/usr/bin/env python3 import os +import sys +import shutil import subprocess -import whisper -import linux -from moviepy import VideoFileClip -from whisper.utils import get_writer from pathlib import Path +from faster_whisper import WhisperModel +from faster_whisper.utils import format_timestamp -model = whisper.load_model("base") +# Prevent OpenMP thread conflicts from crashing the script +os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" -def extract_audio(video_path: str): - # Create a temporary path for a sanitized copy of the video - sanitized_video_path = video_path.replace(".mp4", "_clean.mp4") - audio_temp_path = video_path.replace(".mp4", ".wav") +# 5 minutes per chunk (300 seconds) keeps RAM usage low and stable +CHUNK_DURATION_SEC = 300 + +print("๐Ÿ”ง Initializing C++ Engine...") +model = WhisperModel( + "base", + device="cpu", + compute_type="int8", + cpu_threads=0, # Let CTranslate2 auto-detect safe core counts + num_workers=1 +) +print("โœ… C++ Model loaded successfully.") + + +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.""" + print("๐Ÿš€ Extracting and chunking audio with FFmpeg...") + output_dir.mkdir(parents=True, exist_ok=True) - print("Sanitizing video metadata for MoviePy parser...") - # -map_chapters -1 removes chapter layouts that break the parser. - # -sn strips text/subtitle streams that crash MoviePy. - # -c copy copies video and audio instantly without quality loss. - linux.run_command(f"ffmpeg -y -i {video_path} -map_chapters -1 -sn -c copy {sanitized_video_path}") + # Segment format output: chunk_000.wav, chunk_001.wav, etc. + chunk_pattern = str(output_dir / "chunk_%03d.wav") - print("Extracting uncompressed WAV audio...") + command = [ + "ffmpeg", "-y", "-i", video_path, + "-vn", "-ac", "1", "-ar", "16000", + "-acodec", "pcm_s16le", "-sn", "-map_chapters", "-1", + "-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC), + 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"))) + + +def transcribe_to_srt(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(): + if not force: + print(f"โŒ Error: Transcription file already exists: {srt_path}") + return + else: + srt_path.unlink() + try: - # Load the sanitized file instead of the raw Twitch clip - with VideoFileClip(sanitized_video_path) as video: - video.audio.write_audiofile( - audio_temp_path, - fps=16000, - codec="pcm_s16le", - ffmpeg_params=["-ac", "1"] - ) + # 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"๐Ÿ“ฆ Successfully split audio into {len(audio_chunks)} chunks.") + print("๐ŸŽ™๏ธ Starting safe chunk-by-chunk transcription...") + + global_segment_index = 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})...") + + segments_generator, info = model.transcribe( + str(chunk_path), + beam_size=1, + vad_filter=True, + temperature=0.0 + ) + + # 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 + + start_str = format_timestamp(actual_start, always_include_hours=True) + end_str = format_timestamp(actual_end, always_include_hours=True) + + 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}") + except Exception as e: - print(f"โŒ Error: {e}") - if os.path.exists(audio_temp_path): - os.remove(audio_temp_path) + print(f"\nโŒ Execution Error: {e}") finally: - # Always clean up the temporary sanitized video on Windows 11 - if os.path.exists(sanitized_video_path): - os.remove(sanitized_video_path) - - -def transcribe_to_srt(video_path: str): - - extract_audio(video_path) - - transcribe_path = video_path.replace(".mp4", ".str") - audio_path = video_path.replace(".mp4", ".wav") - print("Transcribing audio...") - result = model.transcribe(audio_path) - - print("Creating SRT file...") - srt_writer = get_writer("srt", Path(transcribe_path).parent) - srt_writer(result, transcribe_path, {}) - - print(f"SRT subtitle file saved in: {transcribe_path}") - if os.path.exists(audio_path): - os.remove(audio_path) + # Clean up the temporary folder entirely + if temp_dir.exists(): + shutil.rmtree(temp_dir) if __name__ == "__main__": - transcribe_to_srt("download/videos/2813112936/2813112936.mp4") \ No newline at end of file + 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}") + else: + transcribe_to_srt(target_video, force=True) diff --git a/twitch_chat_vod.py b/twitch_chat_vod.py index 6dc73e3..2fc4a0f 100644 --- a/twitch_chat_vod.py +++ b/twitch_chat_vod.py @@ -7,6 +7,8 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for Renders Twitch chat JSON to video and combines it with the source VOD. Provides real-time terminal feedback for all processing steps. """ + threads = "8" + video_path = vod_path chat_path = video_path.replace(".mp4", "_chat.json") temp_chat = video_path.replace(".mp4", "_temp_chat.mp4") @@ -14,18 +16,25 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for mask_path = video_path.replace(".mp4", "_temp_chat_mask.mp4") # Step 1: Pre-flight checks - if os.path.exists(video_chat) and force is False: - print(f"โŒ Error: Chat video allready exists: {video_chat}") - return False - if not os.path.exists(video_path): print(f"โŒ Error: Source video not found: {video_path}") return False - + if not os.path.exists(chat_path): print(f"โŒ Error: Chat file not found: {chat_path}") return False + + if os.path.exists(video_chat): + if not force: + print(f"โŒ Error: Chat video already exists: {video_chat}") + return False + else: + os.remove(video_chat) + if os.path.exists(temp_chat): + os.remove(temp_chat) + os.remove(mask_path) + # Step 2: Render Chat to Video print("====================================================") print("๐Ÿš€ STEP 1: Rendering Chat JSON to Video Layer") @@ -37,18 +46,20 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for f"-w 400 -h 1080 " f"--collision Overwrite " f"--temp-path download/temp " - f"--font-size 20 " - f"--generate-mask " + f"--font-size 20 " f"--background-color #00000000 " - f"-o {temp_chat} " + f"-o {temp_chat}" ) + if layout == "overlay": + chat_cmd = (f"{chat_cmd} --generate-mask ") + if not os.path.exists(temp_chat): chat_success = linux.run_command(chat_cmd, look_for=["[STATUS]"]) if chat_success: print("โœ… Success: TwitchDownloaderCLI render chat video.") else: - print(f"โŒ Error: TwitchDownloaderCLI failed to render chat video. {chat_success['error']} : {chat_success['stderr']}") + print("โŒ Error: TwitchDownloaderCLI failed to render chat video.") os.remove(temp_chat) os.remove(mask_path) return False @@ -63,15 +74,16 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for if layout == "side-by-side": ffmpeg_cmd = ( f"ffmpeg -y -i {video_path} -i {temp_chat} " - f"-filter_complex '[0:v][1:v]hstack=inputs=2[v]' " - f"-map '[v]' -map 0:a? -c:a copy -preset superfast {video_chat}" + f"-filter_complex '[1:v]scale=-1:H[scaled_chat];[0:v][scaled_chat]hstack=inputs=2[v]' " + f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy " + f"-threads {threads} {video_chat}" ) elif layout == "overlay": ffmpeg_cmd = ( f"ffmpeg -y -i {video_path} -i {temp_chat} -i {mask_path} " - f"-filter_complex '[1:v][2:v]alphamerge[masked_chat];" - f"[0:v][masked_chat]overlay=x=10:y=10[v]' " - f"-map '[v]' -map 0:a? -c:a copy -preset superfast {video_chat}" + f"-filter_complex '[1:v][2:v]alphamerge[masked_chat];[0:v][masked_chat]overlay=x=0:y=10[v]' " + f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy " + f"-threads {threads} {video_chat}" ) else: raise ValueError("Invalid layout choice. Choose 'side-by-side' or 'overlay'.") @@ -97,4 +109,4 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for return False if __name__ == "__main__": - combine_twitch_vod_and_chat("download/videos/2813112936/2813112936.mp4", "overlay") \ No newline at end of file + combine_twitch_vod_and_chat("download/videos/2813112936/2813112936.mp4", "overlay", True) \ No newline at end of file diff --git a/twitch_download_videos.py b/twitch_download_videos.py index 7e63fad..eb73187 100755 --- a/twitch_download_videos.py +++ b/twitch_download_videos.py @@ -80,6 +80,10 @@ def download(): print("Finished Downloading Pipeline.") -if __name__ == "__main__": +def main(): + global DB DB = Database() - download() \ No newline at end of file + download() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/youtube_short.py b/youtube_short.py index 53d1ef6..2d9d241 100755 --- a/youtube_short.py +++ b/youtube_short.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os +import json import linux import tempfile from pathlib import Path @@ -10,125 +11,133 @@ from pathlib import Path import numpy as np from PIL import Image, ImageFilter -def apply_gaussian_blur(frame, radius: int = 30): - """ - Transforms a single NumPy array frame using PIL's true GaussianBlur filter. - """ - # Convert numpy array to PIL Image - image = Image.fromarray(frame) - # Apply high-quality true Gaussian Blur - blurred_image = image.filter(ImageFilter.GaussianBlur(radius=radius)) - # Return back as a numpy array for MoviePy - return np.array(blurred_image) +def get_video_info(input_path: Path) -> tuple: + """Uses ffprobe to instantly read input video dimensions and frame rate.""" + cmd = f"ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate -of json {input_path}" + # Run command and capture output (assumes linux.run_command prints or you use subprocess) + import subprocess + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + #linux.run_command(cmd) + try: + data = json.loads(result.stdout) + stream = data['streams'][0] + w = int(stream['width']) + h = int(stream['height']) + # Convert fractional FPS string (e.g. "60/1" or "30000/1001") to float + fps_parts = stream['r_frame_rate'].split('/') + fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) > 1 else float(fps_parts[0]) + return w, h, fps + except Exception: + return 1920, 1080, 60.0 # Safe defaults if probe fails -def fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True, force: bool = False): +def fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True, force: bool = False): + threads = "8" input_file = Path(input_path) output_suffix = "gaussian_9_16" if use_blur else "black_9_16" output_path = input_file.parent / f"{input_file.stem}_{output_suffix}{input_file.suffix}" - if os.path.exists(output_path) and force is False: - print(f"โŒ Error: Short video allready exists: {output_path}") - return - + if os.path.exists(output_path): + if not force: + print(f"โŒ Error: Short video already exists: {output_path}") + return + else: + os.remove(output_path) - print("๐Ÿงผ Sanitizing video metadata streams inside an automated safe context...") - with tempfile.NamedTemporaryFile(suffix=input_file.suffix, delete=False) as temp_file: - temp_path = temp_file.name + # 1. Probe input metadata instantly + orig_w, orig_h, fps = get_video_info(input_file) + canvas_w = 1080 + canvas_h = 1920 - bg_scaled = None - bg_cropped = None - background_layer = None + print("โœ๏ธ Generating text overlay graphics via MoviePy...") + # Render static images for text instead of running a video context + title_clip = TextClip( + text=top_text, font_size=55, color="white", font="DejaVuSans-Bold", + text_align="center", size=(canvas_w - 100, 300), method="caption" + ) + bottom_clip = TextClip( + text=bottom_text, font_size=55, color="white", font="DejaVuSans-Bold", + text_align="center", size=(canvas_w - 100, 300), method="caption" + ) + + # Save text layers to temporary PNGs + top_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name + bottom_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name + title_clip.save_frame(top_png) + bottom_clip.save_frame(bottom_png) + + title_clip.close() + bottom_clip.close() + + print("๐ŸŽฌ Dispatching compilation workload to FFmpeg filtergraph...") + + # 2. Build the complex FFmpeg filtergraph + # [0:v] is the raw input video stream + filter_complex = [] + + if use_blur: + # Scale height to 1920, crop center 1080x1920, apply fast boxblur (power of 3 approximates Gaussian) + filter_complex.append( + f"[0:v]scale=-1:{canvas_h},crop={canvas_w}:{canvas_h}:(iw-{canvas_w})/2:0,boxblur=luma_radius=35:luma_power=3[bg];" + ) + else: + # Generate a pure black background canvas matching video frame specs + filter_complex.append( + f"color=c=black:s={canvas_w}x{canvas_h}:r={fps}[bg];" + ) + + # Scale the foreground video to a clean 1080 width, keeping aspect ratio + filter_complex.append( + f"[0:v]scale={canvas_w}:-1[fg];" + ) + + # Layer composition chain: + # Overlay 1: Put scaled foreground onto background (centered vertically) + filter_complex.append( + f"[bg][fg]overlay=0:(H-h)/2[tmp1];" + ) + # Overlay 2: Drop top text asset onto position Y=180 + filter_complex.append( + f"[tmp1][1:v]overlay=(W-w)/2:180[tmp2];" + ) + # Overlay 3: Drop bottom text asset onto position Y=1430 + filter_complex.append( + f"[tmp2][2:v]overlay=(W-w)/2:1430[finalv]" + ) + + filter_graph = "".join(filter_complex) + + # 3. Execute the native assembly command + # -map_chapters -1 -sn: Strips unnecessary metadata chunks instantly + # -c:a copy: Safely pulls original digital audio directly without decompression cycles + # -threads 0: Forces FFmpeg to auto-consume all available processing cores + ffmpeg_cmd = ( + f'ffmpeg -y -v error -i "{input_file}" -i "{top_png}" -i "{bottom_png}" ' + f'-filter_complex "{filter_graph}" ' + f'-map "[finalv]" -map 0:a? -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p ' + f'-c:a copy -map_chapters -1 -sn -threads {threads} "{output_path}"' + ) try: - linux.run_command(f"ffmpeg -y -i {input_file} -map_chapters -1 -sn -c copy {temp_path}") - - # Load video - clip = VideoFileClip(temp_path) - - # Standard vertical 9:16 canvas sizes - canvas_w = 1080 - canvas_h = 1920 - - # Background logic - if use_blur: - print("๐Ÿ“ Scaling, cropping, and blurring background layer...") - bg_scaled = clip.resized(height=canvas_h) - bg_cropped = bg_scaled.cropped(width=canvas_w, x_center=bg_scaled.w / 2) - background_layer = bg_cropped.transform(lambda gf, t: apply_gaussian_blur(gf(t), radius=35)) - else: - print("โšซ Creating solid black background canvas...") - background_layer = ColorClip(size=(canvas_w, canvas_h), color=(0, 0, 0), duration=clip.duration) - - print("๐Ÿ“ Shrinking foreground video width to fit the 1080 wide canvas...") - foreground_clip = clip.resized(width=canvas_w) - - print("โœ๏ธ Creating multi-line top title text clip...") - # FIXED: Added 'method="caption"' and increased vertical size to 300 - title_clip = TextClip( - text=top_text, - font_size=55, # Slightly smaller to accommodate paragraphs comfortably - color="white", - font="DejaVuSans-Bold", - text_align="center", - size=(canvas_w - 100, 300), # Subtracted 100px for safety margins on left/right edges - method="caption", # Forces text to wrap cleanly onto a new line - duration=clip.duration - ) - # Position adjusted to center the taller 300px box in the upper section - positioned_top_text = title_clip.with_position(("center", 180)) - - print("โœ๏ธ Creating multi-line bottom text clip...") - # FIXED: Added 'method="caption"' and increased vertical size to 300 - bottom_clip = TextClip( - text=bottom_text, - font_size=55, - color="white", - font="DejaVuSans-Bold", - text_align="center", - size=(canvas_w - 100, 300), # Left/right margins included - method="caption", # Forces text to wrap cleanly onto a new line - duration=clip.duration - ) - # Position adjusted to center the taller 300px box in the lower section - positioned_bottom_text = bottom_clip.with_position(("center", 1430)) - - # Composite layers - final_clip = CompositeVideoClip( - [ - background_layer, - foreground_clip.with_position("center"), - positioned_top_text, - positioned_bottom_text - ] - ).with_audio(clip.audio) - - print("๐ŸŽฌ Rendering final vertical composition...") - final_clip.write_videofile( - str(output_path), - codec="libx264", - audio_codec="aac", - fps=clip.fps - ) - - # Clean up file locks safely - clip.close() - if bg_scaled: bg_scaled.close() - if bg_cropped: bg_cropped.close() - background_layer.close() - foreground_clip.close() - title_clip.close() - bottom_clip.close() - final_clip.close() - + linux.run_command(ffmpeg_cmd) + print(f"๐ŸŽ‰ High-speed processing complete! Video saved to: {output_path}") finally: - temp_file_path = Path(temp_path) - if temp_file_path.exists(): - temp_file_path.unlink() - - print(f"๐ŸŽ‰ Text overlay video saved to: {output_path}") + # Clean up temporary PNG picture files safely + for path in (top_png, bottom_png): + if os.path.exists(path): + os.unlink(path) + if __name__ == "__main__": + import time + + start_time = time.perf_counter() + creator_name = "greenskiesbluegrass" top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp" bottom_txt = f"Clipped By: {creator_name}." - fit_to_9_16_letterbox("download/clips/AbnegateAgitatedGrassPJSalt/AbnegateAgitatedGrassPJSalt.mp4", top_txt, bottom_txt) \ No newline at end of file + fit_to_9_16_letterbox("download/clips/AbnegateAgitatedGrassPJSalt/AbnegateAgitatedGrassPJSalt.mp4", top_txt, bottom_txt, True, True) + + end_time = time.perf_counter() + execution_time = end_time - start_time + + print(f"The function took {execution_time:.6f} seconds to complete.") \ No newline at end of file