Inital Commit
This commit is contained in:
1
modules/__init__.py
Normal file
1
modules/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import render_engine
|
||||
71
modules/config.py
Normal file
71
modules/config.py
Normal file
@ -0,0 +1,71 @@
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# debugging flag
|
||||
DEBUG = True
|
||||
|
||||
# 🔧 Project Root
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
NAS_MOUNT_ROOT = Path("Z:/")
|
||||
|
||||
# 📁 Assets
|
||||
ASSETS_DIR = PROJECT_ROOT / "assets"
|
||||
|
||||
# 🎵 Theme Music
|
||||
THEME_MUSIC_PATH = ASSETS_DIR / "The_Llama_Song.mp3"
|
||||
|
||||
# Font
|
||||
FONT_PATH = ASSETS_DIR / "BurbankBigCondensed-Black.otf"
|
||||
|
||||
# Brand colors
|
||||
FONT_COLOR = "#f7338f"
|
||||
SHADING_COLOR = "#10abba"
|
||||
SHADOW_COLOR = "#1c0c38"
|
||||
BRANDING_COLORS = {
|
||||
"font": FONT_COLOR,
|
||||
"shade": SHADING_COLOR,
|
||||
"shadow": SHADOW_COLOR
|
||||
}
|
||||
|
||||
# Rendering quality settings (used by render_engine.py)
|
||||
RENDER_PRESET = "slow" # or "medium" for faster encode
|
||||
RENDER_CRF = 18 # lower = better quality, 18–23 is typical
|
||||
|
||||
TITLE_TEMPLATE = {
|
||||
"main": "Fortnite Highlights",
|
||||
"sub": "from livestream",
|
||||
}
|
||||
|
||||
# 🎬 Static Intros and Outros prevetted to 1080p60
|
||||
INTRO_WIDE_PATH = NAS_MOUNT_ROOT / "assets" / "intro-wide-60fps.mp4"
|
||||
OUTRO_WIDE_PATH = NAS_MOUNT_ROOT / "assets" / "outro-wide-60fps.mp4"
|
||||
INTRO_VERTICAL_PATH = NAS_MOUNT_ROOT / "assets" / "intro-vertical-60fps.mp4"
|
||||
OUTRO_VERTICAL_PATH = NAS_MOUNT_ROOT / "assets" / "outro-vertical-60fps.mp4"
|
||||
|
||||
# 🔨 Optional: FFmpeg executable path
|
||||
FFMPEG_PATH = Path("C:/ffmpeg/bin/ffmpeg.exe")
|
||||
|
||||
# 🧠 OpenAI API Key
|
||||
# os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
# 📂 Path resolver (Z: → UNC fallback), now exception wrapped
|
||||
def resolve_path(path_obj: Path) -> str:
|
||||
"""
|
||||
Safely resolves a path for use in subprocess calls.
|
||||
Falls back from Z:/ to UNC if necessary and logs issues.
|
||||
"""
|
||||
try:
|
||||
if path_obj.exists():
|
||||
return str(path_obj)
|
||||
# Try UNC fallback
|
||||
fallback = Path(str(path_obj).replace("Z:/", "//chong/LCS/Videos/eklipse/"))
|
||||
if fallback.exists():
|
||||
return str(fallback)
|
||||
raise FileNotFoundError(f"❌ Path not found: {path_obj} or fallback {fallback}")
|
||||
except Exception as e:
|
||||
logging.error(f"[resolve_path] Failed to resolve: {path_obj} → {e}")
|
||||
raise
|
||||
22
modules/date_utils.py
Normal file
22
modules/date_utils.py
Normal file
@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
def parse_stream_date(path: Path) -> datetime:
|
||||
"""
|
||||
Extracts a datetime object from a stream session folder name.
|
||||
|
||||
Assumes the structure: Z:/YYYY.MM.DD[.N]/category/clip.mp4
|
||||
Always returns the date from the stream folder (two levels up).
|
||||
"""
|
||||
session_folder = path.parent.parent # clip.mp4 → montages → 2025.06.20
|
||||
folder_name = session_folder.name.strip()
|
||||
date_parts = folder_name.split('.')[:3]
|
||||
|
||||
if len(date_parts) != 3:
|
||||
raise ValueError(f"Invalid folder name format: {folder_name}")
|
||||
|
||||
date_str = '.'.join(date_parts)
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y.%m.%d')
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse '{date_str}' from '{folder_name}': {e}")
|
||||
15
modules/format_utils.py
Normal file
15
modules/format_utils.py
Normal file
@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
def detect_format_from_filename(clip_path):
|
||||
"""
|
||||
Determines if a clip is 'wide' or 'vertical' based on its filename.
|
||||
|
||||
Rules:
|
||||
- Filenames ending in -vert.mp4 or -vertical.mp4 → 'vertical'
|
||||
- All others → 'wide'
|
||||
"""
|
||||
filename = os.path.basename(clip_path).lower()
|
||||
if filename.endswith("-vert.mp4") or filename.endswith("-vertical.mp4"):
|
||||
return "vertical"
|
||||
return "wide"
|
||||
|
||||
6
modules/pt_poster.py
Normal file
6
modules/pt_poster.py
Normal file
@ -0,0 +1,6 @@
|
||||
def upload_to_peertube(video_path: Path, title: str, description: str) -> str:
|
||||
"""
|
||||
Stub for PeerTube upload – replace with real implementation later.
|
||||
"""
|
||||
print("⚠️ PeerTube upload not yet implemented.")
|
||||
return "https://peertube.example.com/video-placeholder"
|
||||
66
modules/render_engine.py
Normal file
66
modules/render_engine.py
Normal file
@ -0,0 +1,66 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from modules.config import DEBUG
|
||||
|
||||
def render_montage_clip(
|
||||
title_card_path: Path,
|
||||
montage_path: Path,
|
||||
output_path: Path,
|
||||
intro_path: Path,
|
||||
outro_path: Path,
|
||||
music_path: Path,
|
||||
is_vertical: bool = False,
|
||||
):
|
||||
"""
|
||||
Combines intro (with title), montage, and outro into a final video.
|
||||
Uses ffmpeg for concatenation and audio overlay.
|
||||
"""
|
||||
|
||||
if not title_card_path.exists():
|
||||
raise FileNotFoundError(f"[ERROR] Title card not found: {title_card_path}")
|
||||
if not montage_path.exists():
|
||||
raise FileNotFoundError(f"[ERROR] Montage clip not found: {montage_path}")
|
||||
if not intro_path.exists():
|
||||
raise FileNotFoundError(f"[ERROR] Intro file not found: {intro_path}")
|
||||
if not outro_path.exists():
|
||||
raise FileNotFoundError(f"[ERROR] Outro file not found: {outro_path}")
|
||||
if not music_path.exists():
|
||||
raise FileNotFoundError(f"[ERROR] Music track not found: {music_path}")
|
||||
|
||||
filter_complex = (
|
||||
"[0:v:0]fps=30,setsar=1[v0];"
|
||||
"[1:v:0]fps=30,setsar=1[v1];"
|
||||
"[1:a:0]anull[a1];"
|
||||
"[3:v:0]fps=30,setsar=1[v3];"
|
||||
"[v0][v1][v3]concat=n=3:v=1:a=0[outv];"
|
||||
"[a1][2:a:0]amix=inputs=2:duration=first[outa]"
|
||||
)
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", str(title_card_path), # 0 = intro with title baked in
|
||||
"-i", str(montage_path), # 1 = montage content
|
||||
"-i", str(music_path), # 2 = background music
|
||||
"-i", str(outro_path), # 3 = static outro
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-crf", "23",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
print(f"[DEBUG] Starting render_montage_clip")
|
||||
print(f"[DEBUG] Input files:")
|
||||
print(f" title_card_path: {title_card_path} → {title_card_path.exists()}")
|
||||
print(f" montage_path: {montage_path} → {montage_path.exists()}")
|
||||
print(f" output_path: {output_path}")
|
||||
print(f" output_dir exists? {output_path.parent.exists()}")
|
||||
print(f"[DEBUG] subprocess command: {ffmpeg_cmd}")
|
||||
|
||||
subprocess.run(ffmpeg_cmd, check=True)
|
||||
43
modules/render_montages.py
Normal file
43
modules/render_montages.py
Normal file
@ -0,0 +1,43 @@
|
||||
# modules/render_montages.py
|
||||
#
|
||||
# Entrypoint for rendering Fortnite montage clips.
|
||||
# This module handles parsing the clip metadata and orchestrating the render pipeline.
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from render_engine import render_montage_clip
|
||||
from title_utils import format_overlay_text
|
||||
|
||||
|
||||
def process_montage_clip(
|
||||
clip_path: Path,
|
||||
stream_date: datetime,
|
||||
out_path: Path,
|
||||
is_vertical: bool
|
||||
):
|
||||
"""
|
||||
Handles full processing of a montage clip:
|
||||
- Builds overlay title from stream date
|
||||
- Renders intro with overlay
|
||||
- Stitches intro + clip + outro
|
||||
- Saves final result to out_path
|
||||
"""
|
||||
# Format multiline overlay text using stream date
|
||||
title_text = format_overlay_text(stream_date)
|
||||
|
||||
# Run full montage render pipeline
|
||||
try:
|
||||
print(f"\n[TRACE] about to render:")
|
||||
print(f" montage_path: {montage_path} → {montage_path.exists()}")
|
||||
print(f" title_card_path: {title_card_path} → {title_card_path.exists()}")
|
||||
print(f" output_path: {output_path}")
|
||||
render_montage_clip(
|
||||
montage_path=montage_path,
|
||||
title_card_path=title_card_path,
|
||||
output_path=output_path,
|
||||
is_vertical=is_vertical
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"🔥 Exception BEFORE render_montage_clip: {type(e).__name__} → {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
46
modules/social.py
Normal file
46
modules/social.py
Normal file
@ -0,0 +1,46 @@
|
||||
import openai
|
||||
from pathlib import Path
|
||||
|
||||
def generate_dynamic_description(notes_text: str, date: str, video_type: str) -> str:
|
||||
"""
|
||||
Generates a YouTube description using OpenAI based on notes (if available),
|
||||
video date, and video type.
|
||||
"""
|
||||
base_prompt = (
|
||||
f"Write a fun, engaging YouTube description for a Fortnite {video_type} video "
|
||||
f"from {date}. Include light humor, emoticons, a call to subscribe, and relevant hashtags. "
|
||||
f"Include reference to the host, Gramps, and his whacky senile playstyle in solo zero build gameplay."
|
||||
)
|
||||
|
||||
if notes_text.strip():
|
||||
prompt = f"{base_prompt}\n\nAdditional context:\n{notes_text.strip()}"
|
||||
else:
|
||||
prompt = base_prompt
|
||||
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.9
|
||||
)
|
||||
|
||||
return response['choices'][0]['message']['content']
|
||||
|
||||
def upload_video(video_path: Path, title: str, description: str, is_vertical: bool):
|
||||
"""
|
||||
Main upload dispatcher:
|
||||
- Always uploads to YouTube.
|
||||
- Uploads to PeerTube only if the video is NOT vertical.
|
||||
"""
|
||||
print(f"📤 Uploading to YouTube: {video_path.name}")
|
||||
yt_url = upload_to_youtube(video_path, title, description, is_short=is_vertical)
|
||||
|
||||
pt_url = None
|
||||
if not is_vertical:
|
||||
print(f"📤 Uploading to PeerTube: {video_path.name}")
|
||||
# Placeholder: Implement actual PeerTube upload function.
|
||||
pt_url = upload_to_peertube(video_path, title, description)
|
||||
|
||||
return {
|
||||
"youtube": yt_url,
|
||||
"peertube": pt_url,
|
||||
}
|
||||
46
modules/startup.py
Normal file
46
modules/startup.py
Normal file
@ -0,0 +1,46 @@
|
||||
# startup.py
|
||||
#
|
||||
# Description:
|
||||
# This module verifies the presence and accessibility of all critical assets needed for the video processing pipeline.
|
||||
# If any required file is missing or unreadable, the script exits with an error message.
|
||||
#
|
||||
# Usage:
|
||||
# Called at the beginning of main.py to ensure a clean, verified startup state.
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# These are expected to be already set correctly in config.py
|
||||
from config import (
|
||||
INTRO_WIDE_PATH,
|
||||
INTRO_VERTICAL_PATH,
|
||||
OUTRO_WIDE_PATH,
|
||||
OUTRO_VERTICAL_PATH,
|
||||
FONT_PATH,
|
||||
THEME_MUSIC_PATH
|
||||
)
|
||||
|
||||
REQUIRED_PATHS = [
|
||||
("INTRO_WIDE_PATH", INTRO_WIDE_PATH),
|
||||
("INTRO_VERTICAL_PATH", INTRO_VERTICAL_PATH),
|
||||
("OUTRO_WIDE_PATH", OUTRO_WIDE_PATH),
|
||||
("OUTRO_VERTICAL_PATH", OUTRO_VERTICAL_PATH),
|
||||
("FONT_PATH", FONT_PATH),
|
||||
("THEME_MUSIC_PATH", THEME_MUSIC_PATH),
|
||||
]
|
||||
|
||||
def resolve_path(label: str, path_str: str):
|
||||
try:
|
||||
path = Path(path_str)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"{label} not found at {path}")
|
||||
return path
|
||||
except Exception as e:
|
||||
print(f"❌ {label} → {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def verify_assets():
|
||||
print("🔍 Verifying external file dependencies...")
|
||||
for label, path_str in REQUIRED_PATHS:
|
||||
resolved = resolve_path(label, path_str)
|
||||
print(f"✅ {label} → {resolved}")
|
||||
115
modules/title_utils.py
Normal file
115
modules/title_utils.py
Normal file
@ -0,0 +1,115 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_stream_date(clip_path: Path) -> datetime:
|
||||
"""
|
||||
Extracts the stream date from a montage clip path by parsing its grandparent directory name.
|
||||
Assumes format: YYYY.MM.DD or YYYY.MM.DD.N
|
||||
"""
|
||||
parent_dir = clip_path.parents[1]
|
||||
dir_name = parent_dir.name.split(".")
|
||||
if len(dir_name) < 3:
|
||||
raise ValueError(f"Invalid directory name format: {parent_dir.name}")
|
||||
year, month, day = map(int, dir_name[:3])
|
||||
return datetime(year, month, day)
|
||||
|
||||
|
||||
def extract_session_metadata(clip_path: Path) -> str:
|
||||
"""
|
||||
Returns the session directory name as metadata tag (e.g. '2025.07.01' or '2025.07.01.2')
|
||||
"""
|
||||
return clip_path.parents[1].name
|
||||
|
||||
|
||||
def generate_output_filename(clip_path: Path) -> str:
|
||||
"""
|
||||
Generates output filename from the session name, following rules:
|
||||
- Vertical clips get suffix `-vert`
|
||||
- Suffix .N in session becomes `-videoN`
|
||||
"""
|
||||
session_name = extract_session_metadata(clip_path)
|
||||
date_parts = session_name.split(".")
|
||||
base_date = "".join(date_parts[:3]) # e.g., 20250701
|
||||
suffix = f"-video{date_parts[3]}" if len(date_parts) > 3 else ""
|
||||
vert = "-vert" if clip_path.stem.endswith(("-vert", "-vertical")) else ""
|
||||
return f"Fortnite-montage-{base_date}{suffix}{vert}.mp4"
|
||||
|
||||
|
||||
def format_overlay_text(title: str, subtitle: str, date_str: str) -> list[str]:
|
||||
"""
|
||||
Returns three lines for the overlay text.
|
||||
"""
|
||||
return [title, subtitle, date_str]
|
||||
|
||||
|
||||
def generate_title_overlay(
|
||||
intro_path: Path,
|
||||
overlay_text: list[str],
|
||||
output_path: Path,
|
||||
font_path: Path,
|
||||
is_vertical: bool = False,
|
||||
):
|
||||
"""
|
||||
Overlays title text on top of the intro clip and creates a new video segment.
|
||||
The text fades out completely 0.5 seconds before the intro ends.
|
||||
"""
|
||||
width, height = (1080, 1920) if is_vertical else (1920, 1080)
|
||||
fade_start = 4.5
|
||||
fade_duration = 0.5
|
||||
|
||||
# Uniform visual settings
|
||||
fontcolor = "#f7338f"
|
||||
shadowcolor = "0x1c0c38"
|
||||
boxcolor = "0x10abba@0.5"
|
||||
fontsize = 64
|
||||
y_offsets = [0, 80, 160] # vertical positions for each line
|
||||
|
||||
# Escape Windows-style font path
|
||||
escaped_font_path = str(font_path).replace("\\", "\\\\")
|
||||
|
||||
drawtext_filters = []
|
||||
for i, (line, y_offset) in enumerate(zip(overlay_text, y_offsets)):
|
||||
drawtext = (
|
||||
f"drawtext=text='{line}':"
|
||||
f"fontfile='{escaped_font_path}':"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y=(h/2)-90+{y_offset}:"
|
||||
f"fontsize={fontsize}:"
|
||||
f"fontcolor={fontcolor}:"
|
||||
f"shadowcolor={shadowcolor}:"
|
||||
f"shadowx=2:shadowy=2:"
|
||||
f"box=1:boxcolor={boxcolor}"
|
||||
)
|
||||
drawtext_filters.append(drawtext)
|
||||
|
||||
drawtext_filters.append(f"fade=t=out:st={fade_start}:d={fade_duration}:alpha=1")
|
||||
drawtext_filter = ",".join(drawtext_filters)
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", str(intro_path),
|
||||
"-vf", drawtext_filter,
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast",
|
||||
"-t", "5",
|
||||
"-pix_fmt", "yuv420p",
|
||||
str(output_path)
|
||||
]
|
||||
|
||||
subprocess.run(ffmpeg_cmd, check=True)
|
||||
|
||||
|
||||
def generate_montage_title(session_name: str) -> str:
|
||||
"""
|
||||
Generates YouTube/PeerTube title for montage videos.
|
||||
Example:
|
||||
'#Fortnite #Solo #Zerobuild #Highlights with Gramps from July 1, 2025'
|
||||
"""
|
||||
parts = session_name.split(".")
|
||||
year, month, day = map(int, parts[:3])
|
||||
suffix = f" Video {parts[3]}" if len(parts) > 3 else ""
|
||||
date_str = datetime(year, month, day).strftime("%B %-d, %Y")
|
||||
return f"#Fortnite #Solo #Zerobuild #Highlights with Gramps from {date_str}{suffix}"
|
||||
44
modules/utils.py
Normal file
44
modules/utils.py
Normal file
@ -0,0 +1,44 @@
|
||||
# modules/utils.py
|
||||
#
|
||||
# General-purpose utility functions used throughout the rendering pipeline.
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from modules.config import NAS_MOUNT_ROOT, resolve_path # type: ignore
|
||||
|
||||
# Regex pattern to match date formats like '2025.06.20', '2025.06.20.2', etc.
|
||||
DATE_PATTERN = re.compile(r"^(\d{4})\.(\d{2})\.(\d{2})(?:\.(\d{1,2}))?$")
|
||||
|
||||
|
||||
def scan_for_new_clips(base_path: Path, subfolder: str) -> list[Path]:
|
||||
"""
|
||||
Recursively scan base_path for any files under a named subfolder
|
||||
(e.g. 'montages', 'hits', etc).
|
||||
Returns a list of all video files found.
|
||||
"""
|
||||
matching_clips = []
|
||||
|
||||
for session_dir in base_path.iterdir():
|
||||
if session_dir.is_dir() and session_dir.name.count(".") >= 2:
|
||||
target_dir = session_dir / subfolder
|
||||
if target_dir.exists():
|
||||
for f in target_dir.glob("*.mp4"):
|
||||
matching_clips.append(f)
|
||||
|
||||
return matching_clips
|
||||
|
||||
def run_ffmpeg(cmd: list[str]) -> None:
|
||||
"""
|
||||
Execute an ffmpeg command, logging output and raising if the command fails.
|
||||
"""
|
||||
import subprocess
|
||||
from textwrap import indent
|
||||
|
||||
print(f"\n🛠️ Running ffmpeg:\n{indent(' '.join(cmd), ' ')}\n")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ FFmpeg failed with error: {e}")
|
||||
raise
|
||||
88
modules/yt_poster.py
Normal file
88
modules/yt_poster.py
Normal file
@ -0,0 +1,88 @@
|
||||
import os
|
||||
import pickle, logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
from modules.title_utils import get_output_filename, generate_montage_title
|
||||
|
||||
# Define OAuth scopes and token paths
|
||||
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
|
||||
TOKEN_PATH = Path("token.pickle")
|
||||
CLIENT_SECRETS_FILE = Path("client_secrets.json")
|
||||
|
||||
def authenticate_youtube():
|
||||
"""Handles YouTube OAuth flow and returns a service client."""
|
||||
creds = None
|
||||
|
||||
if TOKEN_PATH.exists():
|
||||
with open(TOKEN_PATH, "rb") as token_file:
|
||||
creds = pickle.load(token_file)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
if not CLIENT_SECRETS_FILE.exists():
|
||||
raise FileNotFoundError("client_secrets.json not found.")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
str(CLIENT_SECRETS_FILE), SCOPES
|
||||
)
|
||||
creds = flow.run_local_server(port=0)
|
||||
with open(TOKEN_PATH, "wb") as token_file:
|
||||
pickle.dump(creds, token_file)
|
||||
|
||||
return build("youtube", "v3", credentials=creds)
|
||||
|
||||
def generate_description(clip_path: Path, stream_date: datetime, is_montage: bool = False) -> str:
|
||||
"""Creates a dynamic and fun YouTube description."""
|
||||
kill_count_guess = sum(word.isdigit() for word in clip_path.stem.split())
|
||||
date_str = stream_date.strftime("%B %d, %Y")
|
||||
|
||||
intro = "Gramps is back in Fortnite with another spicy highlight! 🦥"
|
||||
if is_montage:
|
||||
body = (
|
||||
f"This reel features an outrageous compilation of top plays from our {date_str} stream.\n"
|
||||
f"{kill_count_guess} eliminations of stupendous magnitude that must be seen to be believed!"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"Recorded live on {date_str}, this clip captures one of many wild moments "
|
||||
"from the battlefield. Grab your popcorn. 🎮"
|
||||
)
|
||||
|
||||
hashtags = "#Fortnite #Gaming #SeniorGamer #LlamaChileShop #EpicMoments"
|
||||
|
||||
return f"{intro}\n\n{body}\n\nSubscribe for more: https://youtube.com/@llamachileshop\n{hashtags}"
|
||||
|
||||
def upload_to_youtube(video_path: Path, title: str, description: str, is_short: bool = False) -> str:
|
||||
"""Uploads the video to YouTube and returns the video URL."""
|
||||
youtube = authenticate_youtube()
|
||||
|
||||
request_body = {
|
||||
"snippet": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"tags": ["Fortnite", "Gaming", "Senior Gamer", "LlamaChileShop"],
|
||||
"categoryId": "20", # Gaming
|
||||
},
|
||||
"status": {
|
||||
"privacyStatus": "private",
|
||||
"selfDeclaredMadeForKids": False,
|
||||
}
|
||||
}
|
||||
|
||||
media = MediaFileUpload(str(video_path), mimetype="video/mp4", resumable=True)
|
||||
|
||||
request = youtube.videos().insert(
|
||||
part="snippet,status",
|
||||
body=request_body,
|
||||
media_body=media
|
||||
)
|
||||
|
||||
response = request.execute()
|
||||
video_id = response["id"]
|
||||
return f"https://youtu.be/{video_id}"
|
||||
Reference in New Issue
Block a user