putting it all togeather
This commit is contained in:
+8
-4
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
+106
-49
@@ -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("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"]
|
||||
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()
|
||||
|
||||
try:
|
||||
# Step 1: Split audio into bite-sized pieces
|
||||
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
|
||||
if not audio_chunks:
|
||||
print("❌ Error: No audio chunks were generated.")
|
||||
return
|
||||
|
||||
print(f"📦 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)
|
||||
|
||||
+24
-12
@@ -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}"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -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
@@ -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.")
|
||||
Reference in New Issue
Block a user