putting it all togeather

This commit is contained in:
2026-08-04 02:34:16 +00:00
parent d6503b7d92
commit 33d78759fe
7 changed files with 282 additions and 179 deletions
+8 -4
View File
@@ -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():
@@ -104,8 +104,12 @@ def build_shorts():
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()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -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"""
+17
View File
@@ -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()
+104 -47
View File
@@ -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("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}")
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)
# 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",
"-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()
print("Extracting uncompressed WAV audio...")
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")
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)
+25 -13
View File
@@ -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,10 +16,6 @@ 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
@@ -26,6 +24,17 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for
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")
@@ -38,17 +47,19 @@ def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", for
f"--collision Overwrite "
f"--temp-path download/temp "
f"--font-size 20 "
f"--generate-mask "
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")
combine_twitch_vod_and_chat("download/videos/2813112936/2813112936.mp4", "overlay", True)
+5 -1
View File
@@ -80,6 +80,10 @@ def download():
print("Finished Downloading Pipeline.")
if __name__ == "__main__":
def main():
global DB
DB = Database()
download()
if __name__ == "__main__":
main()
+105 -96
View File
@@ -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):
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}")
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
bg_scaled = None
bg_cropped = None
background_layer = None
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
# 1. Probe input metadata instantly
orig_w, orig_h, fps = get_video_info(input_file)
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
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, # 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
text=top_text, font_size=55, color="white", font="DejaVuSans-Bold",
text_align="center", size=(canvas_w - 100, 300), method="caption"
)
# 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
text=bottom_text, font_size=55, color="white", font="DejaVuSans-Bold",
text_align="center", size=(canvas_w - 100, 300), method="caption"
)
# 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()
# 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()
final_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(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()
# Clean up temporary PNG picture files safely
for path in (top_png, bottom_png):
if os.path.exists(path):
os.unlink(path)
print(f"🎉 Text overlay video saved to: {output_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)
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.")