putting it all togeather
This commit is contained in:
+8
-4
@@ -33,7 +33,7 @@ def build_chat_video():
|
|||||||
print(f"🚀 Transcribe: {title}")
|
print(f"🚀 Transcribe: {title}")
|
||||||
print("====================================================")
|
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():
|
def build_transcribe():
|
||||||
@@ -68,7 +68,7 @@ def build_transcribe():
|
|||||||
print(f"🚀 Transcribe: {title}")
|
print(f"🚀 Transcribe: {title}")
|
||||||
print("====================================================")
|
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():
|
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)
|
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()
|
DB = database.Database()
|
||||||
#build_shorts()
|
build_shorts()
|
||||||
build_transcribe()
|
build_transcribe()
|
||||||
build_chat_video()
|
build_chat_video()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1
-1
@@ -91,7 +91,7 @@ class Database:
|
|||||||
|
|
||||||
def get_unuploaded_chats(self) -> list[Any]:
|
def get_unuploaded_chats(self) -> list[Any]:
|
||||||
"""Retrieve all rows that were download but not uploaded_yt_chats"""
|
"""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]:
|
def get_unuploaded(self) -> list[Any]:
|
||||||
"""Retrieve all rows that were download but not uploaded_yt"""
|
"""Retrieve all rows that were download but not uploaded_yt"""
|
||||||
|
|||||||
@@ -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()
|
||||||
+105
-48
@@ -1,62 +1,119 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import whisper
|
|
||||||
import linux
|
|
||||||
from moviepy import VideoFileClip
|
|
||||||
from whisper.utils import get_writer
|
|
||||||
from pathlib import Path
|
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):
|
# 5 minutes per chunk (300 seconds) keeps RAM usage low and stable
|
||||||
# Create a temporary path for a sanitized copy of the video
|
CHUNK_DURATION_SEC = 300
|
||||||
sanitized_video_path = video_path.replace(".mp4", "_clean.mp4")
|
|
||||||
audio_temp_path = video_path.replace(".mp4", ".wav")
|
|
||||||
|
|
||||||
print("Sanitizing video metadata for MoviePy parser...")
|
print("🔧 Initializing C++ Engine...")
|
||||||
# -map_chapters -1 removes chapter layouts that break the parser.
|
model = WhisperModel(
|
||||||
# -sn strips text/subtitle streams that crash MoviePy.
|
"base",
|
||||||
# -c copy copies video and audio instantly without quality loss.
|
device="cpu",
|
||||||
linux.run_command(f"ffmpeg -y -i {video_path} -map_chapters -1 -sn -c copy {sanitized_video_path}")
|
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:
|
try:
|
||||||
# Load the sanitized file instead of the raw Twitch clip
|
# Step 1: Split audio into bite-sized pieces
|
||||||
with VideoFileClip(sanitized_video_path) as video:
|
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
|
||||||
video.audio.write_audiofile(
|
if not audio_chunks:
|
||||||
audio_temp_path,
|
print("❌ Error: No audio chunks were generated.")
|
||||||
fps=16000,
|
return
|
||||||
codec="pcm_s16le",
|
|
||||||
ffmpeg_params=["-ac", "1"]
|
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:
|
except Exception as e:
|
||||||
print(f"❌ Error: {e}")
|
print(f"\n❌ Execution Error: {e}")
|
||||||
if os.path.exists(audio_temp_path):
|
|
||||||
os.remove(audio_temp_path)
|
|
||||||
finally:
|
finally:
|
||||||
# Always clean up the temporary sanitized video on Windows 11
|
# Clean up the temporary folder entirely
|
||||||
if os.path.exists(sanitized_video_path):
|
if temp_dir.exists():
|
||||||
os.remove(sanitized_video_path)
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
@@ -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.
|
Renders Twitch chat JSON to video and combines it with the source VOD.
|
||||||
Provides real-time terminal feedback for all processing steps.
|
Provides real-time terminal feedback for all processing steps.
|
||||||
"""
|
"""
|
||||||
|
threads = "8"
|
||||||
|
|
||||||
video_path = vod_path
|
video_path = vod_path
|
||||||
chat_path = video_path.replace(".mp4", "_chat.json")
|
chat_path = video_path.replace(".mp4", "_chat.json")
|
||||||
temp_chat = video_path.replace(".mp4", "_temp_chat.mp4")
|
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")
|
mask_path = video_path.replace(".mp4", "_temp_chat_mask.mp4")
|
||||||
|
|
||||||
# Step 1: Pre-flight checks
|
# 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):
|
if not os.path.exists(video_path):
|
||||||
print(f"❌ Error: Source video not found: {video_path}")
|
print(f"❌ Error: Source video not found: {video_path}")
|
||||||
return False
|
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}")
|
print(f"❌ Error: Chat file not found: {chat_path}")
|
||||||
return False
|
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
|
# Step 2: Render Chat to Video
|
||||||
print("====================================================")
|
print("====================================================")
|
||||||
print("🚀 STEP 1: Rendering Chat JSON to Video Layer")
|
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"--collision Overwrite "
|
||||||
f"--temp-path download/temp "
|
f"--temp-path download/temp "
|
||||||
f"--font-size 20 "
|
f"--font-size 20 "
|
||||||
f"--generate-mask "
|
|
||||||
f"--background-color #00000000 "
|
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):
|
if not os.path.exists(temp_chat):
|
||||||
chat_success = linux.run_command(chat_cmd, look_for=["[STATUS]"])
|
chat_success = linux.run_command(chat_cmd, look_for=["[STATUS]"])
|
||||||
if chat_success:
|
if chat_success:
|
||||||
print("✅ Success: TwitchDownloaderCLI render chat video.")
|
print("✅ Success: TwitchDownloaderCLI render chat video.")
|
||||||
else:
|
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(temp_chat)
|
||||||
os.remove(mask_path)
|
os.remove(mask_path)
|
||||||
return False
|
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":
|
if layout == "side-by-side":
|
||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
f"ffmpeg -y -i {video_path} -i {temp_chat} "
|
f"ffmpeg -y -i {video_path} -i {temp_chat} "
|
||||||
f"-filter_complex '[0:v][1:v]hstack=inputs=2[v]' "
|
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:a copy -preset superfast {video_chat}"
|
f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy "
|
||||||
|
f"-threads {threads} {video_chat}"
|
||||||
)
|
)
|
||||||
elif layout == "overlay":
|
elif layout == "overlay":
|
||||||
ffmpeg_cmd = (
|
ffmpeg_cmd = (
|
||||||
f"ffmpeg -y -i {video_path} -i {temp_chat} -i {mask_path} "
|
f"ffmpeg -y -i {video_path} -i {temp_chat} -i {mask_path} "
|
||||||
f"-filter_complex '[1:v][2:v]alphamerge[masked_chat];"
|
f"-filter_complex '[1:v][2:v]alphamerge[masked_chat];[0:v][masked_chat]overlay=x=0:y=10[v]' "
|
||||||
f"[0:v][masked_chat]overlay=x=10:y=10[v]' "
|
f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy "
|
||||||
f"-map '[v]' -map 0:a? -c:a copy -preset superfast {video_chat}"
|
f"-threads {threads} {video_chat}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid layout choice. Choose 'side-by-side' or 'overlay'.")
|
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
|
return False
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
@@ -80,6 +80,10 @@ def download():
|
|||||||
|
|
||||||
print("Finished Downloading Pipeline.")
|
print("Finished Downloading Pipeline.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main():
|
||||||
|
global DB
|
||||||
DB = Database()
|
DB = Database()
|
||||||
download()
|
download()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+113
-104
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import linux
|
import linux
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,125 +11,133 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageFilter
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
def apply_gaussian_blur(frame, radius: int = 30):
|
def get_video_info(input_path: Path) -> tuple:
|
||||||
"""
|
"""Uses ffprobe to instantly read input video dimensions and frame rate."""
|
||||||
Transforms a single NumPy array frame using PIL's true GaussianBlur filter.
|
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)
|
||||||
# Convert numpy array to PIL Image
|
import subprocess
|
||||||
image = Image.fromarray(frame)
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||||
# Apply high-quality true Gaussian Blur
|
#linux.run_command(cmd)
|
||||||
blurred_image = image.filter(ImageFilter.GaussianBlur(radius=radius))
|
try:
|
||||||
# Return back as a numpy array for MoviePy
|
data = json.loads(result.stdout)
|
||||||
return np.array(blurred_image)
|
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)
|
input_file = Path(input_path)
|
||||||
output_suffix = "gaussian_9_16" if use_blur else "black_9_16"
|
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}"
|
output_path = input_file.parent / f"{input_file.stem}_{output_suffix}{input_file.suffix}"
|
||||||
|
|
||||||
if os.path.exists(output_path) and force is False:
|
if os.path.exists(output_path):
|
||||||
print(f"❌ Error: Short video allready exists: {output_path}")
|
if not force:
|
||||||
return
|
print(f"❌ Error: Short video already exists: {output_path}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
os.remove(output_path)
|
||||||
|
|
||||||
|
# 1. Probe input metadata instantly
|
||||||
|
orig_w, orig_h, fps = get_video_info(input_file)
|
||||||
|
canvas_w = 1080
|
||||||
|
canvas_h = 1920
|
||||||
|
|
||||||
print("🧼 Sanitizing video metadata streams inside an automated safe context...")
|
print("✍️ Generating text overlay graphics via MoviePy...")
|
||||||
with tempfile.NamedTemporaryFile(suffix=input_file.suffix, delete=False) as temp_file:
|
# Render static images for text instead of running a video context
|
||||||
temp_path = temp_file.name
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
bg_scaled = None
|
# Save text layers to temporary PNGs
|
||||||
bg_cropped = None
|
top_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
||||||
background_layer = None
|
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:
|
try:
|
||||||
linux.run_command(f"ffmpeg -y -i {input_file} -map_chapters -1 -sn -c copy {temp_path}")
|
linux.run_command(ffmpeg_cmd)
|
||||||
|
print(f"🎉 High-speed processing complete! Video saved to: {output_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()
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
temp_file_path = Path(temp_path)
|
# Clean up temporary PNG picture files safely
|
||||||
if temp_file_path.exists():
|
for path in (top_png, bottom_png):
|
||||||
temp_file_path.unlink()
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
print(f"🎉 Text overlay video saved to: {output_path}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import time
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
creator_name = "greenskiesbluegrass"
|
creator_name = "greenskiesbluegrass"
|
||||||
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
|
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}."
|
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.")
|
||||||
Reference in New Issue
Block a user