Inital Commit

This commit is contained in:
2025-07-23 11:52:09 -07:00
commit d98bd6aa17
25 changed files with 1554 additions and 0 deletions

1
modules/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import render_engine

71
modules/config.py Normal file
View 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, 1823 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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
View 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
View 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
View 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}"