Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7de2f62c | |||
| d473b05d89 | |||
| 3eeaa32759 | |||
| 01ac83a342 | |||
| 75055dca56 | |||
| 90c5d6992e | |||
| 14500c479e | |||
| 41f0ddb0c1 | |||
| 2c08fce88f | |||
| 73666d3987 | |||
| ed85fba609 | |||
| 0a7387447c | |||
| 96ca63c299 | |||
| 087e96b7e6 | |||
| ce19fce7f7 | |||
| e9fc694970 | |||
| ea62fa34d0 | |||
| 652061b914 | |||
| e4d4a6c15f | |||
| 22a51dc7ae | |||
| 0c8e8f2661 | |||
| 961c43fbd5 | |||
| 2b8bf4ce19 | |||
| 2f6740eb54 | |||
| 6c5850b1aa |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: llamachileshop
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
merch: 'https://llamachile.support'
|
||||||
65
.gitignore
vendored
65
.gitignore
vendored
@ -1,44 +1,65 @@
|
|||||||
# Byte-compiled / cache
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
|
*.so
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
# VS Code settings
|
# test data
|
||||||
|
2025.06.20/
|
||||||
|
|
||||||
|
# VSCode
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Tokens and API keys
|
# Environment variables and secrets
|
||||||
.env
|
.env
|
||||||
|
client_secrets.json
|
||||||
token.pickle
|
token.pickle
|
||||||
token.zip
|
description_gen.py
|
||||||
token (2).zip
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.mp4
|
|
||||||
*.mov
|
|
||||||
*.mp3
|
|
||||||
*.zip
|
|
||||||
*.odt
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
# Assets not for versioning
|
# Jupyter Notebook checkpoints
|
||||||
assets/*.mp4
|
.ipynb_checkpoints/
|
||||||
assets/*.mp3
|
|
||||||
assets/*.png
|
|
||||||
assets/*.otf
|
|
||||||
|
|
||||||
# Processed data
|
# Compiled C extensions
|
||||||
202*/**/rendered/
|
*.c
|
||||||
202*/**/*.mp4
|
*.o
|
||||||
|
*.obj
|
||||||
|
*.dll
|
||||||
|
*.a
|
||||||
|
*.lib
|
||||||
|
*.exp
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Test and coverage
|
||||||
|
.coverage
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.cache/
|
||||||
|
pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.tmp
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "video-pipeline.wiki"]
|
||||||
|
path = video-pipeline.wiki
|
||||||
|
url = https://github.com/LCS-Gramps/video-pipeline.wiki.git
|
||||||
86
README.md
86
README.md
@ -1,19 +1,83 @@
|
|||||||
# Llama Chile Shop Video Automation Pipeline
|
# 🎥 LCS Pipeline
|
||||||
|
|
||||||
This project automates the rendering, branding, and publishing of Fortnite gameplay clips for YouTube and PeerTube.
|
Automated livestream highlight rendering and publishing for Fortnite content featuring Gramps.
|
||||||
|
|
||||||
## Features
|
This project powers the backend of [Llama Chile Shop](https://www.youtube.com/@llamachileshop), transforming raw livestream clips into polished, uploaded videos — complete with titles, thumbnails, intros/outros, and social metadata.
|
||||||
|
|
||||||
- Auto-detection of new stream folders
|
---
|
||||||
- Dynamic title card overlay
|
|
||||||
- Automated rendering and social post generation
|
|
||||||
- Vertical & widescreen output
|
|
||||||
|
|
||||||
## Setup
|
## ⚙️ Features
|
||||||
|
|
||||||
1. Clone the repo.
|
* ✅ Daily folder scan for new stream sessions (2025‑07‑10) \[`v0.1.0`]
|
||||||
2. Create a `.env` file (see `ENVIRONMENT.md` for required keys).
|
* 📂 Clip classification (`hits/`, `misses/`, `montages/`, `outtakes/`, `timelapses/`) (2025‑08‑07) \[`v0.1.2`]
|
||||||
3. Install dependencies:
|
* 🧠 AI‑generated titles and descriptions via OpenAI (2025‑07‑10) \[`v0.1.0`]
|
||||||
|
* 🎬 Auto‑stitched intro + title card + outro (2025‑07‑23) \[`v0.1.0`]
|
||||||
|
* 🖼️ Dynamic thumbnail creation with Fortnite styling (2025‑07‑25) \[`v0.1.0`]
|
||||||
|
* ⬆️ Uploads to YouTube (2025‑07‑29) and PeerTube (2025‑08‑07) \[`v0.1.1` & `v0.1.2`]
|
||||||
|
* 📜 Metadata archive and session history (2025‑07‑26) \[`v0.1.0`]
|
||||||
|
* 🐘 (Planned) Social posts to Mastodon and Bluesky (2025‑07‑20) \[`v0.2.0`]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://llgit.llamachile.tube/gramps/video-pipeline.git
|
||||||
|
cd video-pipeline
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # Fill in your API keys and config
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> Requires Python 3.13+ and access to mapped NAS directory (e.g. `Z:\2025.08.05\hits\`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
video-pipeline/
|
||||||
|
├── main.py
|
||||||
|
├── config.py
|
||||||
|
├── .env.example
|
||||||
|
├── modules/
|
||||||
|
│ ├── render_engine.py
|
||||||
|
│ ├── title_utils.py
|
||||||
|
│ ├── thumbnail_utils.py
|
||||||
|
│ ├── yt_poster.py
|
||||||
|
│ └── ...
|
||||||
|
├── assets/ # Branding assets (intros, fonts, logos)
|
||||||
|
├── logs/ # Sync logs, wiki publish logs, etc.
|
||||||
|
└── metadata/
|
||||||
|
└── history/ # Per-clip metadata archive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Full documentation is hosted in the [📖 Gitea Wiki](https://llgit.llamachile.tube/gramps/video-pipeline/wiki)
|
||||||
|
|
||||||
|
Recommended pages:
|
||||||
|
|
||||||
|
* 🏠 [Home](https://llgit.llamachile.tube/gramps/video-pipeline/wiki)
|
||||||
|
* 🎯 [Clip Handling Logic](https://llgit.llamachile.tube/gramps/video-pipeline/wiki/Clip-Handling-Logic)
|
||||||
|
* 🗃️ [Metadata Extraction](https://llgit.llamachile.tube/gramps/video-pipeline/wiki/Metadata-Extraction)
|
||||||
|
* 📺 [YouTube Upload Logic](https://llgit.llamachile.tube/gramps/video-pipeline/wiki/YouTube-Upload-Logic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Development Mode
|
||||||
|
|
||||||
|
* `DEBUG=True` in `.env` disables destructive operations
|
||||||
|
* All modules can be run/tested independently
|
||||||
|
* Wiki editing is supported via local Markdown and `wiki_publish.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 About
|
||||||
|
|
||||||
|
Created by Gramps for Llama Chile Shop — a custom content pipeline for old-school gaming chaos.
|
||||||
|
|
||||||
|
> Maintainer: `gramps@llamachile.shop`
|
||||||
|
> Contributions welcome in the form of bug reports, pull requests, or Fortnite gifts.
|
||||||
|
|||||||
BIN
assets/LCS Video Text Filler.odt
Normal file
BIN
assets/LCS Video Text Filler.odt
Normal file
Binary file not shown.
@ -1,25 +1,61 @@
|
|||||||
|
"""
|
||||||
|
authorize_youtube.py
|
||||||
|
|
||||||
|
Handles OAuth2 authorization for the YouTube Data API.
|
||||||
|
|
||||||
|
This module loads the client_secrets.json file and generates an authorized
|
||||||
|
YouTube API service object for use by other modules. The token is cached
|
||||||
|
in token.pickle to avoid repeated authorization.
|
||||||
|
|
||||||
|
Author: gramps@llamachile.shop
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Automatically locate this file's directory (e.g., \\chong\LCS\Videos\eklipse)
|
from google.auth.transport.requests import Request
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
modules_dir = os.path.join(project_root, "modules")
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
# Add modules directory to the Python path
|
# Scopes define what access is requested from the YouTube API
|
||||||
sys.path.insert(0, modules_dir)
|
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
|
||||||
|
|
||||||
# Change working directory so relative paths (like client_secrets.json) resolve
|
# Default token and client secret filenames
|
||||||
os.chdir(modules_dir)
|
TOKEN_PATH = "token.pickle"
|
||||||
|
CLIENT_SECRET_FILE = "client_secrets.json"
|
||||||
# Import from yt_poster in modules
|
|
||||||
from yt_poster import authenticate_youtube
|
|
||||||
|
|
||||||
# Run the OAuth flow
|
|
||||||
print("🔐 Starting YouTube OAuth authorization...")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
def get_authenticated_service():
|
||||||
service = authenticate_youtube()
|
"""
|
||||||
print("✅ YouTube authorization complete.")
|
Returns an authorized YouTube API client.
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Authorization failed: {e}")
|
If the token does not exist or is expired, initiates the OAuth flow.
|
||||||
|
Requires client_secrets.json in project root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
googleapiclient.discovery.Resource: Authenticated YouTube service
|
||||||
|
"""
|
||||||
|
creds = None
|
||||||
|
|
||||||
|
# Check if token.pickle exists
|
||||||
|
if Path(TOKEN_PATH).exists():
|
||||||
|
with open(TOKEN_PATH, "rb") as token:
|
||||||
|
creds = pickle.load(token)
|
||||||
|
|
||||||
|
# If no valid creds, go through OAuth flow
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
print("🔐 Starting YouTube OAuth authorization...")
|
||||||
|
if not Path(CLIENT_SECRET_FILE).exists():
|
||||||
|
raise FileNotFoundError(f"Missing required file: {CLIENT_SECRET_FILE}")
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE, SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
|
||||||
|
# Save the credentials for future use
|
||||||
|
with open(TOKEN_PATH, "wb") as token:
|
||||||
|
pickle.dump(creds, token)
|
||||||
|
|
||||||
|
return build("youtube", "v3", credentials=creds)
|
||||||
|
|||||||
3
docs/wiki/Home.md
Normal file
3
docs/wiki/Home.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Home
|
||||||
|
|
||||||
|
_TODO: Add content here._
|
||||||
2
docs/wiki/TestSync.md
Normal file
2
docs/wiki/TestSync.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
Testing wiki sync trigger @ 07/27/2025 20:16:57
|
||||||
@ -5,7 +5,8 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# debugging flag
|
# debugging flag
|
||||||
DEBUG = True
|
DEBUG = os.getenv("DEBUG_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
# 🔧 Project Root
|
# 🔧 Project Root
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|||||||
67
modules/description_utils.py
Normal file
67
modules/description_utils.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
description_utils.py
|
||||||
|
|
||||||
|
Utility functions for generating video descriptions dynamically using OpenAI's API.
|
||||||
|
Includes brand-aware humor, format-aware descriptions, and dynamic prompt generation.
|
||||||
|
|
||||||
|
This module currently supports:
|
||||||
|
- Montage descriptions (fun, quirky, "Cool-Hand Gramps" themed)
|
||||||
|
|
||||||
|
Author: Llama Chile Shop
|
||||||
|
Created: 2025-07-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import openai
|
||||||
|
|
||||||
|
# 🛠 Global debug flag (imported by design elsewhere)
|
||||||
|
from modules.config import DEBUG
|
||||||
|
|
||||||
|
# Set up OpenAI API key from environment
|
||||||
|
openai.api_key = os.getenv("OPENAI_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_montage_description() -> str:
|
||||||
|
"""
|
||||||
|
Generates a creative, humorous description for a montage highlight video.
|
||||||
|
Leverages the "Cool-Hand Gramps" branding identity and inserts dynamic randomness
|
||||||
|
to keep each description fresh and engaging.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A YouTube/PeerTube-ready video description.
|
||||||
|
"""
|
||||||
|
# 🎲 Add entropy to reduce prompt caching / same-seed behavior
|
||||||
|
creativity_seed = random.randint(0, 999999)
|
||||||
|
|
||||||
|
# 🧠 Base template for the prompt
|
||||||
|
prompt = f"""
|
||||||
|
You are a branding-savvy copywriter helping a YouTube gaming channel called "Llama Chile Shop"
|
||||||
|
run by a quirky and beloved senior gamer named "Gramps." Gramps is known for his calm demeanor,
|
||||||
|
sharp shooting, and whacky senile playstyle in Solo Zero Build Fortnite matches. His fans refer
|
||||||
|
to him as "Cool-Hand Gramps" because his heart rate doesn’t rise, even in intense firefights.
|
||||||
|
|
||||||
|
Write a YouTube/PeerTube video description for a highlight montage from one of Gramps' livestreams.
|
||||||
|
Make it short, funny, and on-brand. Include emoticons and hashtags. Add a sentence encouraging viewers
|
||||||
|
to subscribe and check out the stream calendar.
|
||||||
|
|
||||||
|
Entropy seed: {creativity_seed}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = openai.ChatCompletion.create(
|
||||||
|
model="gpt-4",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are a creative and humorous copywriter."},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
temperature=0.9,
|
||||||
|
max_tokens=250
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
fallback = "Join Gramps for another action-packed Fortnite montage! Subscribe and watch live ➡ https://youtube.com/@llamachileshop 🎮🦙 #Fortnite #CoolHandGramps"
|
||||||
|
if DEBUG:
|
||||||
|
print(f"[ERROR] Failed to generate montage description: {e}")
|
||||||
|
return fallback
|
||||||
121
modules/metadata_utils.py
Normal file
121
modules/metadata_utils.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
metadata_utils.py
|
||||||
|
|
||||||
|
Handles metadata extraction from video clip structure and notes.json,
|
||||||
|
and manages persistent storage of finalized metadata records.
|
||||||
|
|
||||||
|
Author: Llama Chile Shop
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from modules.config import NAS_MOUNT_ROOT
|
||||||
|
|
||||||
|
# Define where to persist finalized metadata records after upload
|
||||||
|
HISTORY_DIR = Path("Z:/LCS/Logs/processed")
|
||||||
|
|
||||||
|
|
||||||
|
def derive_session_metadata(session_dir: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Derives session-level metadata from a session directory.
|
||||||
|
Includes shared attributes, notes.json contents, and clip metadata for all videos found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_dir (Path): Path to the session folder (e.g., 2025.07.24 or 2025.07.24.2)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary representing session metadata, including notes and per-clip info.
|
||||||
|
"""
|
||||||
|
session_dir = Path(session_dir)
|
||||||
|
session_name = session_dir.name
|
||||||
|
|
||||||
|
# Validate session folder format: YYYY.MM.DD or YYYY.MM.DD.N
|
||||||
|
match = re.match(r"(\d{4})\.(\d{2})\.(\d{2})(?:\.(\d+))?", session_name)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid session folder format: {session_name}")
|
||||||
|
|
||||||
|
year, month, day, session_index = match.groups()
|
||||||
|
session_date = f"{year}-{month}-{day}"
|
||||||
|
session_number = int(session_index) if session_index else 1
|
||||||
|
|
||||||
|
# Attempt to load notes.json from the session root
|
||||||
|
notes_path = session_dir / "notes.json"
|
||||||
|
notes_data = {}
|
||||||
|
if notes_path.exists():
|
||||||
|
try:
|
||||||
|
with open(notes_path, "r", encoding="utf-8") as f:
|
||||||
|
notes_data = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to parse notes.json: {e}")
|
||||||
|
|
||||||
|
# Extract shared fields (with fallback defaults)
|
||||||
|
session_meta = {
|
||||||
|
"session_date": session_date,
|
||||||
|
"session_number": session_number,
|
||||||
|
"highlight": notes_data.get("highlight", "Fortnite highlight moment"),
|
||||||
|
"tags": notes_data.get("tags", []),
|
||||||
|
"gag_name": notes_data.get("gag_name", None),
|
||||||
|
"notes": notes_data,
|
||||||
|
"clips": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scan for all .mp4 clips within expected subdirectories
|
||||||
|
for subfolder in ["hits", "misses", "montages", "outtakes"]:
|
||||||
|
clip_dir = session_dir / subfolder
|
||||||
|
if not clip_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for clip_path in clip_dir.glob("*.mp4"):
|
||||||
|
stem = clip_path.stem.lower()
|
||||||
|
is_vertical = stem.endswith("-vert") or stem.endswith("-vertical")
|
||||||
|
format = "vertical" if is_vertical else "wide"
|
||||||
|
|
||||||
|
clip_meta = {
|
||||||
|
"path": str(clip_path),
|
||||||
|
"filename": clip_path.name,
|
||||||
|
"stem": clip_path.stem,
|
||||||
|
"format": format,
|
||||||
|
"clip_type": subfolder,
|
||||||
|
"youtube_urls": [],
|
||||||
|
"peertube_urls": []
|
||||||
|
}
|
||||||
|
|
||||||
|
session_meta["clips"].append(clip_meta)
|
||||||
|
|
||||||
|
return session_meta
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata_record(metadata: dict) -> None:
|
||||||
|
"""
|
||||||
|
Saves a finalized metadata record to disk for future lookup or audit.
|
||||||
|
|
||||||
|
This includes all session-level and clip-level data, plus any added URLs
|
||||||
|
after upload to YouTube or PeerTube.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata (dict): Fully populated metadata record, typically post-upload.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If required fields are missing or write fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session_date = metadata.get("session_date")
|
||||||
|
filename = metadata.get("filename") or metadata.get("stem")
|
||||||
|
|
||||||
|
if not session_date or not filename:
|
||||||
|
raise ValueError("Metadata missing required fields: session_date or filename/stem")
|
||||||
|
|
||||||
|
# Use YYYY.MM.DD folder for archival
|
||||||
|
dest_dir = HISTORY_DIR / session_date.replace("-", ".")
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Save as <stem>.json
|
||||||
|
dest_file = dest_dir / f"{Path(filename).stem}.json"
|
||||||
|
with open(dest_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
print(f"📁 Saved metadata record to: {dest_file}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to save metadata record: {e}")
|
||||||
64
modules/thumbnail_utils.py
Normal file
64
modules/thumbnail_utils.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def generate_thumbnail(video_path: str, output_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a thumbnail image from the midpoint of the given video.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
video_path (str): Path to the input video file.
|
||||||
|
output_path (str): Path where the thumbnail image (JPEG) should be saved.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to the generated thumbnail image.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Uses FFmpeg to extract a frame using the 'thumbnail' filter.
|
||||||
|
- Thumbnail will be scaled to 1280x720 resolution (16:9).
|
||||||
|
- Overwrites the output file if it already exists.
|
||||||
|
"""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
output_path = Path(output_path)
|
||||||
|
|
||||||
|
if not video_path.exists():
|
||||||
|
raise FileNotFoundError(f"Video file not found: {video_path}")
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", # Overwrite output if exists
|
||||||
|
"-i", str(video_path),
|
||||||
|
"-vf", "thumbnail,scale=1280:720",
|
||||||
|
"-frames:v", "1",
|
||||||
|
str(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to generate thumbnail: {e}") from e
|
||||||
|
|
||||||
|
if not output_path.exists():
|
||||||
|
raise RuntimeError(f"Thumbnail was not created: {output_path}")
|
||||||
|
|
||||||
|
return str(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail_prompt(notes: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a rich thumbnail prompt from a descriptive sentence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
notes (str): A brief sentence describing the video content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A thumbnail generation prompt for OpenAI or DALL·E.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
f"Create a Fortnite-style gaming thumbnail based on the moment: \"{notes.strip()}\" "
|
||||||
|
f"featuring a stylized llama character with bold comic-style colors. Include dramatic or humorous elements "
|
||||||
|
f"(e.g., explosions, dance emotes, intense lighting), and text like 'HIGHLIGHT' or 'VICTORY ROYALE'. "
|
||||||
|
f"Use the Llama Chile Shop color palette (f7338f, 10abba, 1c0c38). The vibe should be fun, exaggerated, "
|
||||||
|
f"and chill — inviting viewers to laugh and enjoy the moment."
|
||||||
|
)
|
||||||
@ -111,5 +111,5 @@ def generate_montage_title(session_name: str) -> str:
|
|||||||
parts = session_name.split(".")
|
parts = session_name.split(".")
|
||||||
year, month, day = map(int, parts[:3])
|
year, month, day = map(int, parts[:3])
|
||||||
suffix = f" Video {parts[3]}" if len(parts) > 3 else ""
|
suffix = f" Video {parts[3]}" if len(parts) > 3 else ""
|
||||||
date_str = datetime(year, month, day).strftime("%B %-d, %Y")
|
date_str = datetime(year, month, day).strftime("%B %d, %Y").replace(" 0", " ")
|
||||||
return f"#Fortnite #Solo #Zerobuild #Highlights with Gramps from {date_str}{suffix}"
|
return f"#Fortnite #Solo #Zerobuild #Highlights with Gramps from {date_str}{suffix}"
|
||||||
|
|||||||
@ -1,82 +1,84 @@
|
|||||||
import os
|
#!/usr/bin/env python3
|
||||||
import pickle, logging
|
"""
|
||||||
from pathlib import Path
|
yt_poster.py
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
This module handles the upload of videos to YouTube using the YouTube Data API v3.
|
||||||
from google.auth.transport.requests import Request
|
It supports setting metadata such as title, description, tags, category, and privacy settings.
|
||||||
|
It also ensures that the game title "Fortnite" is included in the metadata to trigger proper categorization.
|
||||||
|
|
||||||
|
Author: gramps@llamachile.shop
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import google.auth
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.http import MediaFileUpload
|
from googleapiclient.http import MediaFileUpload
|
||||||
from modules.title_utils import get_output_filename, generate_montage_title
|
|
||||||
|
|
||||||
# Define OAuth scopes and token paths
|
from modules.config import OPENAI_API_KEY, DEBUG
|
||||||
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
|
from modules.archive import save_metadata_record
|
||||||
TOKEN_PATH = Path("token.pickle")
|
|
||||||
CLIENT_SECRETS_FILE = Path("client_secrets.json")
|
|
||||||
|
|
||||||
def authenticate_youtube():
|
# Category ID for "Gaming" on YouTube (required for accurate categorization)
|
||||||
"""Handles YouTube OAuth flow and returns a service client."""
|
CATEGORY_ID = "20"
|
||||||
creds = None
|
|
||||||
|
|
||||||
if TOKEN_PATH.exists():
|
# Default tags to include if none are provided
|
||||||
with open(TOKEN_PATH, "rb") as token_file:
|
DEFAULT_TAGS = [
|
||||||
creds = pickle.load(token_file)
|
"Fortnite", "Zero Build", "Gramps", "CoolHandGramps",
|
||||||
|
"funny", "gaming", "highlights"
|
||||||
|
]
|
||||||
|
|
||||||
if not creds or not creds.valid:
|
# Default visibility setting
|
||||||
if creds and creds.expired and creds.refresh_token:
|
DEFAULT_PRIVACY = "public"
|
||||||
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 ensure_fortnite_tag(metadata):
|
||||||
|
"""
|
||||||
|
Ensures that the word 'Fortnite' appears in at least one of the following:
|
||||||
|
- Title
|
||||||
|
- Description
|
||||||
|
- Tags list
|
||||||
|
|
||||||
def generate_description(clip_path: Path, stream_date: datetime, is_montage: bool = False) -> str:
|
This helps YouTube automatically detect the game and associate the video
|
||||||
"""Creates a dynamic and fun YouTube description."""
|
with Fortnite gameplay.
|
||||||
kill_count_guess = sum(word.isdigit() for word in clip_path.stem.split())
|
"""
|
||||||
date_str = stream_date.strftime("%B %d, %Y")
|
if "fortnite" not in metadata["title"].lower() and \
|
||||||
|
"fortnite" not in metadata["description"].lower() and \
|
||||||
|
not any("fortnite" in tag.lower() for tag in metadata.get("tags", [])):
|
||||||
|
metadata.setdefault("tags", []).append("Fortnite")
|
||||||
|
|
||||||
intro = "Gramps is back in Fortnite with another spicy highlight! 🦥"
|
def upload_video(youtube, video_path, metadata):
|
||||||
if is_montage:
|
"""
|
||||||
body = (
|
Uploads a video to YouTube with the provided metadata.
|
||||||
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"
|
Args:
|
||||||
|
youtube: Authenticated YouTube API service object.
|
||||||
|
video_path: Path to the video file to be uploaded.
|
||||||
|
metadata: Dictionary containing video metadata fields.
|
||||||
|
|
||||||
return f"{intro}\n\n{body}\n\nSubscribe for more: https://youtube.com/@llamachileshop\n{hashtags}"
|
Returns:
|
||||||
|
str: URL of the uploaded YouTube video.
|
||||||
|
"""
|
||||||
|
|
||||||
def upload_to_youtube(video_path: Path, title: str, description: str, is_short: bool = False) -> str:
|
# Ensure the 'Fortnite' keyword is present somewhere in metadata
|
||||||
"""Uploads the video to YouTube and returns the video URL."""
|
ensure_fortnite_tag(metadata)
|
||||||
youtube = authenticate_youtube()
|
|
||||||
|
|
||||||
|
# Construct the request body for YouTube API
|
||||||
request_body = {
|
request_body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": title,
|
"title": metadata["title"],
|
||||||
"description": description,
|
"description": metadata["description"],
|
||||||
"tags": ["Fortnite", "Gaming", "Senior Gamer", "LlamaChileShop"],
|
"tags": metadata.get("tags", DEFAULT_TAGS),
|
||||||
"categoryId": "20", # Gaming
|
"categoryId": CATEGORY_ID # Set to "Gaming"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"privacyStatus": "private",
|
"privacyStatus": metadata.get("privacy", DEFAULT_PRIVACY)
|
||||||
"selfDeclaredMadeForKids": False,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
media = MediaFileUpload(str(video_path), mimetype="video/mp4", resumable=True)
|
# Wrap the video file in a MediaFileUpload object
|
||||||
|
media = MediaFileUpload(video_path, mimetype="video/*", resumable=True)
|
||||||
|
|
||||||
|
print(f"📤 Uploading {video_path} to YouTube...")
|
||||||
|
|
||||||
|
# Execute the video insert request
|
||||||
request = youtube.videos().insert(
|
request = youtube.videos().insert(
|
||||||
part="snippet,status",
|
part="snippet,status",
|
||||||
body=request_body,
|
body=request_body,
|
||||||
@ -85,4 +87,29 @@ def upload_to_youtube(video_path: Path, title: str, description: str, is_short:
|
|||||||
|
|
||||||
response = request.execute()
|
response = request.execute()
|
||||||
video_id = response["id"]
|
video_id = response["id"]
|
||||||
return f"https://youtu.be/{video_id}"
|
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
|
print(f"✅ Uploaded to YouTube: {youtube_url}")
|
||||||
|
|
||||||
|
# Record the YouTube URL in the metadata for archive history
|
||||||
|
metadata.setdefault("youtube_url", []).append(youtube_url)
|
||||||
|
|
||||||
|
# Persist the metadata archive only if we're not in DEBUG mode
|
||||||
|
if not DEBUG:
|
||||||
|
save_metadata_record(video_path, metadata)
|
||||||
|
|
||||||
|
return youtube_url
|
||||||
|
|
||||||
|
def get_authenticated_service():
|
||||||
|
"""
|
||||||
|
Returns an authenticated YouTube API service using Application Default Credentials.
|
||||||
|
This requires that `gcloud auth application-default login` has been run successfully,
|
||||||
|
or that a service account token is available in the environment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
googleapiclient.discovery.Resource: The YouTube API client object.
|
||||||
|
"""
|
||||||
|
credentials, _ = google.auth.default(
|
||||||
|
scopes=["https://www.googleapis.com/auth/youtube.upload"]
|
||||||
|
)
|
||||||
|
return build("youtube", "v3", credentials=credentials)
|
||||||
|
|||||||
BIN
sanity_check.md
Normal file
BIN
sanity_check.md
Normal file
Binary file not shown.
106
sync_wiki.py
Normal file
106
sync_wiki.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# sync_wiki.py
|
||||||
|
"""
|
||||||
|
🚨 DEPRECATED: This script was used to manually sync wiki pages via local `.md` files.
|
||||||
|
It is now kept as a fallback ('parachute') in case automated token-based publishing fails.
|
||||||
|
|
||||||
|
✅ DO NOT use this unless instructed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# This entire file is now considered inactive and will not be maintained unless token publishing breaks.
|
||||||
|
# All real wiki publishing is handled via automated memory-based GPT-side tools.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
WIKI_DIR = "video-pipeline.wiki"
|
||||||
|
LOG_FILE = "logs/wiki_publish.log"
|
||||||
|
GITHUB_REPO = "LCS-Gramps/video-pipeline"
|
||||||
|
WIKI_BASE_URL = f"https://github.com/{GITHUB_REPO}/wiki"
|
||||||
|
|
||||||
|
def log_result(filename, success):
|
||||||
|
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||||
|
with open(LOG_FILE, "a", encoding="utf-8") as log:
|
||||||
|
status = "✅" if success else "❌"
|
||||||
|
timestamp = datetime.now().isoformat(timespec='seconds')
|
||||||
|
log.write(f"{timestamp} {status} {filename}\n")
|
||||||
|
|
||||||
|
def commit_and_push():
|
||||||
|
# Explicitly list and add all .md files
|
||||||
|
md_files = [f for f in os.listdir(WIKI_DIR) if f.endswith(".md")]
|
||||||
|
if not md_files:
|
||||||
|
print("⚠️ No markdown files found to commit.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for f in md_files:
|
||||||
|
subprocess.run(["git", "add", f], cwd=WIKI_DIR, check=True)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "commit", "-m", "📚 Sync updated wiki pages from docs/wiki"],
|
||||||
|
cwd=WIKI_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if "nothing to commit" in result.stdout.lower():
|
||||||
|
print("⚠️ Nothing to commit.")
|
||||||
|
return
|
||||||
|
print(result.stdout.strip())
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print("❌ Git add/commit failed:", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["git", "push", "origin", "master"], cwd=WIKI_DIR, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_publish():
|
||||||
|
for file in os.listdir(WIKI_DIR):
|
||||||
|
if file.endswith(".md"):
|
||||||
|
name = file.replace(".md", "").replace(" ", "-")
|
||||||
|
url = f"{WIKI_BASE_URL}/{name}"
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
success = response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
success = False
|
||||||
|
log_result(file, success)
|
||||||
|
print(f"{'✅' if success else '❌'} {url}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("📝 Auto-generating wiki content...")
|
||||||
|
os.makedirs(WIKI_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
autogen_content = {
|
||||||
|
"Architecture-Overview.md": """# Architecture Overview
|
||||||
|
|
||||||
|
This page provides an overview of the internal structure of the LCS Pipeline.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
- `main.py`: Central orchestration logic
|
||||||
|
- `modules/`: Reusable utilities for title cards, thumbnails, uploads
|
||||||
|
- `assets/`: Contains branding videos and fonts
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
1. Detect new video sessions
|
||||||
|
2. Generate metadata, titles, overlays
|
||||||
|
3. Render videos with intro/title/outro
|
||||||
|
4. Upload to YouTube and optionally PeerTube
|
||||||
|
5. Auto-publish wiki and social metadata
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only create or update files explicitly listed
|
||||||
|
for filename, content in autogen_content.items():
|
||||||
|
filepath = os.path.join(WIKI_DIR, filename)
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content.strip())
|
||||||
|
print(f"✅ Created or updated {filename}")
|
||||||
|
|
||||||
|
commit_and_push()
|
||||||
|
verify_publish()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
20
tests/conftest.py
Normal file
20
tests/conftest.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# tests/conftest.py
|
||||||
|
"""
|
||||||
|
Shared pytest fixtures and constants for testing the LCS video pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_session_path() -> Path:
|
||||||
|
"""
|
||||||
|
Fixture providing the fixed test session directory.
|
||||||
|
|
||||||
|
NOTE: This directory must exist and be preserved. It contains test clips
|
||||||
|
and notes.json used by multiple tests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: Absolute path to test session folder.
|
||||||
|
"""
|
||||||
|
return Path("Z:/LCS/Videos/eklipse/2025.07.25.9")
|
||||||
44
tests/sync_wiki.py
Normal file
44
tests/sync_wiki.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sync_wiki.py
|
||||||
|
|
||||||
|
Synchronizes local markdown files in docs/wiki/ to the GitHub wiki
|
||||||
|
for the Llama Chile Shop video pipeline project.
|
||||||
|
|
||||||
|
Requires the GitHub wiki repo to be cloned into ./video-pipeline.wiki/.
|
||||||
|
|
||||||
|
Author: gramps@llamachile.shop
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
print("🧠 THIS IS THE CORRECT sync_wiki.py")
|
||||||
|
|
||||||
|
# Correct paths for wiki sync
|
||||||
|
LOCAL_WIKI_SOURCE = Path("docs/wiki")
|
||||||
|
LOCAL_WIKI_REPO = Path("video-pipeline.wiki")
|
||||||
|
print("🔍 Executing: sync_wiki.py from", __file__)
|
||||||
|
|
||||||
|
def sync_wiki():
|
||||||
|
if not LOCAL_WIKI_REPO.exists():
|
||||||
|
print("❌ Wiki repo not found. Clone it using:")
|
||||||
|
print(" git clone https://github.com/LCS-Gramps/video-pipeline.wiki.git")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Copy .md files to the local wiki repo
|
||||||
|
for md_file in LOCAL_WIKI_SOURCE.glob("*.md"):
|
||||||
|
target = LOCAL_WIKI_REPO / md_file.name
|
||||||
|
shutil.copy2(md_file, target)
|
||||||
|
print(f"✅ Synced: {md_file.name}")
|
||||||
|
|
||||||
|
# Commit and push changes
|
||||||
|
os.chdir(LOCAL_WIKI_REPO)
|
||||||
|
subprocess.run(["git", "add", "."], check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", "📚 Sync updated wiki pages from docs/wiki"], check=True)
|
||||||
|
subprocess.run(["git", "push"], check=True)
|
||||||
|
print("🚀 Wiki updated successfully.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync_wiki()
|
||||||
0
tests/test_full_pipeline.py
Normal file
0
tests/test_full_pipeline.py
Normal file
52
tests/test_metadata_utils.py
Normal file
52
tests/test_metadata_utils.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# tests/test_metadata_utils.py
|
||||||
|
"""
|
||||||
|
Unit tests for metadata parsing and archiving functions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from modules.metadata_utils import derive_session_metadata, save_metadata_record
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def test_derive_session_metadata_structure(test_session_path):
|
||||||
|
"""
|
||||||
|
Validates that metadata is parsed correctly and includes expected keys.
|
||||||
|
"""
|
||||||
|
metadata = derive_session_metadata(test_session_path)
|
||||||
|
|
||||||
|
assert "session_date" in metadata
|
||||||
|
assert "clips" in metadata
|
||||||
|
assert isinstance(metadata["clips"], list)
|
||||||
|
assert len(metadata["clips"]) > 0, "Expected at least one clip in metadata"
|
||||||
|
|
||||||
|
for clip in metadata["clips"]:
|
||||||
|
assert "stem" in clip
|
||||||
|
assert "highlight" in clip or "notes" in clip
|
||||||
|
assert clip["format"] in ("wide", "vertical")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_metadata_record_creates_file(tmp_path):
|
||||||
|
"""
|
||||||
|
Ensures metadata is saved to a properly named JSON file.
|
||||||
|
"""
|
||||||
|
fake_record = {
|
||||||
|
"session_date": "2025-07-25",
|
||||||
|
"stem": "test-clip",
|
||||||
|
"youtube_urls": ["https://youtu.be/test123"],
|
||||||
|
"peertube_urls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Override history dir to a temp path
|
||||||
|
from modules import metadata_utils
|
||||||
|
metadata_utils.HISTORY_DIR = tmp_path
|
||||||
|
|
||||||
|
save_metadata_record(fake_record)
|
||||||
|
|
||||||
|
expected_dir = tmp_path / "2025.07.25"
|
||||||
|
expected_file = expected_dir / "test-clip.json"
|
||||||
|
|
||||||
|
assert expected_file.exists(), f"Expected {expected_file} to be created"
|
||||||
|
|
||||||
|
with expected_file.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["youtube_urls"][0] == "https://youtu.be/test123"
|
||||||
0
tests/test_title_utils.py
Normal file
0
tests/test_title_utils.py
Normal file
0
tests/test_yt_poster.py
Normal file
0
tests/test_yt_poster.py
Normal file
63
upload_youtube_montage.py
Normal file
63
upload_youtube_montage.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
upload_montage_youtube.py
|
||||||
|
|
||||||
|
Standalone entry point to upload a rendered Fortnite montage video to YouTube.
|
||||||
|
Assumes that the input video is a montage and therefore does NOT rely on a notes.* file.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Validating input parameters (video path)
|
||||||
|
- Deriving vertical format from filename
|
||||||
|
- Generating dynamic description via OpenAI
|
||||||
|
- Uploading to YouTube with appropriate metadata
|
||||||
|
- Flagging video as private if DEBUG is enabled
|
||||||
|
|
||||||
|
Author: Llama Chile Shop
|
||||||
|
Created: 2025-07-22
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from modules.config import DEBUG
|
||||||
|
from modules.yt_poster import upload_video
|
||||||
|
from modules.description_utils import generate_montage_description
|
||||||
|
from authorize_youtube import get_authenticated_service
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Entry point to handle YouTube upload of montage video.
|
||||||
|
Usage:
|
||||||
|
python upload_montage_youtube.py <video_path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python upload_montage_youtube.py <path_to_rendered_video>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Extract stream date from parent directory (Z:\2025.06.20)
|
||||||
|
video_path = Path(sys.argv[1])
|
||||||
|
stream_date = video_path.parents[1].name # '2025.06.20'
|
||||||
|
|
||||||
|
if not os.path.isfile(video_path):
|
||||||
|
print(f"[ERROR] File not found: {video_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
video_name = os.path.basename(video_path)
|
||||||
|
is_vertical = "-vert" in video_path.stem or "-vertical" in video_path.stem
|
||||||
|
|
||||||
|
# Generate a dynamic, humorous montage description
|
||||||
|
description = generate_montage_description()
|
||||||
|
|
||||||
|
# Upload the video to YouTube
|
||||||
|
upload_video(
|
||||||
|
file_path=video_path,
|
||||||
|
is_vertical=is_vertical,
|
||||||
|
stream_date=stream_date,
|
||||||
|
description=description,
|
||||||
|
private=DEBUG
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user