Files
python_scripts/youtube_short.py
T
2026-07-21 16:50:05 -04:00

248 lines
9.3 KiB
Python

#!/usr/bin/env python3
import argparse
import sys
import subprocess
import tempfile
from pathlib import Path
from moviepy import VideoFileClip, ColorClip, CompositeVideoClip
from moviepy.video.VideoClip import TextClip
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 fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True):
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}"
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:
cleanup_cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-map_chapters", "-1", "-sn",
"-c", "copy", temp_path
]
subprocess.run(cleanup_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# 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:
temp_file_path = Path(temp_path)
if temp_file_path.exists():
temp_file_path.unlink()
print(f"🎉 Text overlay video saved to: {output_path}")
def convert_to_short(input_path: str):
input_file = Path(input_path)
# Generate paths using pathlib
sanitized_input_path = input_file.parent / f"{input_file.stem}_clean{input_file.suffix}"
output_path = input_file.parent / f"{input_file.stem}_shorts{input_file.suffix}"
print("Sanitizing video metadata for MoviePy parser...")
cleanup_cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-map_chapters", "-1", "-sn",
"-c", "copy", str(sanitized_input_path)
]
subprocess.run(cleanup_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Load the sanitized video file
clip = VideoFileClip(str(sanitized_input_path))
# 1. Check video duration
if clip.duration >= 60:
print(f"❌ Error: Video is {clip.duration:.2f}s long. It must be under 60 seconds.")
clip.close()
if sanitized_input_path.exists():
sanitized_input_path.unlink()
return False
print(f"✅ Video length check passed ({clip.duration:.2f}s). Processing...")
# 2. Calculate the target width for a 9:16 aspect ratio based on height
target_height = clip.h
target_width = int(target_height * (9 / 16))
# 3. FIX FOR v2.x: Use native .cropped() method directly on the clip
print("🎬 Cropping video to 9:16 vertical view...")
short_clip = clip.cropped(
width=target_width,
height=target_height,
x_center=clip.w / 2,
y_center=clip.h / 2
)
# 4. FIX FOR v2.x: Use native .resized() method directly on the clip
if short_clip.h != 1920:
print("📐 Resizing frame resolution to standard 1080x1920 Shorts format...")
short_clip = short_clip.resized(new_size=(1080, 1920))
# 5. Export the final video file
short_clip.write_videofile(
str(output_path),
codec="libx264",
audio_codec="aac",
fps=clip.fps
)
# Clean up files and close streams
clip.close()
short_clip.close()
if sanitized_input_path.exists():
sanitized_input_path.unlink()
print(f"🎉 Success! Short saved as: {output_path}")
return True
def upload_shorts():
"""Loops over the downloaded videos entries and uploaded them to youtube."""
print("Uploading Shorts...")
unuploaded_shorts = DB.get_unuploaded()
categoryId = CategoryId.GAMING
for slug, record_date, title, game_name, clip_by, views, downloaded, uploaded_yt in unuploaded_shorts:
print(f"Slug: {slug} | Date: {record_date} | Game: {game_name} | By: {clip_by} | Views: {views} | Title: {title}")
file_path = f"save/clips/{slug}/{slug}_shorts.mp4"
title = title
description = f"Game: {game_name}, Cliped By: {clip_by}, on {record_date}, #Shorts #Clips #Twitch Every Friday and Sunday @7:30 EST https://twitch.tv/teampgp"
categoryId = CategoryId.GAMING
privatcyStatus = 'private'
tags = ['shorts', 'gaming', 'TeamPGP', f'{game_name}', f'{clip_by}', 'twitch_clips', 'clips', 'Level1Techs', 'twitch']
output = uploader.upload_video(file_path, title, categoryId, description, privatcyStatus, tags)
if output is True:
print(f"Slug: {slug} | Was successfully uploaded.")
DB.mark_as_uploaded(slug)
else:
print(f"Slug: {slug} | Upload Process failed.")
def main():
# Set up the command-line argument parser
parser = argparse.ArgumentParser(description="TeamPGP Clip Processing and Upload Pipeline")
# Add optional arguments
parser.add_argument('--convert', type=str, metavar='CLIP_PATH', help='Path to a video file to convert to a 9:16 Short')
parser.add_argument('--upload', action='store_true', help='Process and upload pending Shorts in the database to YouTube')
args = parser.parse_args()
# If no flags are provided, show help text and exit
if not args.convert and not args.upload:
parser.print_help()
sys.exit("\n❌ Error: You must provide at least one action flag (--convert or --upload).")
# Execute conversion step if path is provided
if args.convert:
clip_by = "joelmckinney"
#convert_to_short(args.convert)
fit_to_9_16_letterbox(args.convert, "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp", f"Clipped By: {clip_by}.", True)
# Execute database upload step if flag is provided
if args.upload:
upload_shorts()
if __name__ == "__main__":
main()