LLMVideoPipeline/youtube_factory.py
2026-08-16 09:39:36 -06:00

456 lines
21 KiB
Python
Executable file

#!/usr/bin/env python3
"""
YouTube Factory — Master Orchestrator for 24/7 Faceless Video Generation
Distributed across 3 AI PCs:
- AMDLLM: LLM Scriptwriting, Translation & Trending Ideation (llama.cpp Qwen3.5-7B)
- NvidiaLLM: ComfyUI MiniMax H3 Video Gen, z-image-turbo / SDXL & A/B Thumbnails
- IntelLLM: Kokoro TTS (EN+ES), Faster-Whisper Subtitles & FFmpeg Assembly with Audio Ducking
"""
import os
import sys
import json
import yaml
import time
import re
import argparse
import subprocess
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from pipeline.remote import RemotePC
from pipeline.trending import TrendingFetcher
from pipeline.script_gen import ScriptGen
from pipeline.tts_gen import TTSGen
from pipeline.music_gen import MusicGen
from pipeline.visual_gen import VisualGen
from pipeline.sub_gen import SubGen
from pipeline.thumb_gen import ThumbnailGen
from pipeline.assembler import Assembler
from pipeline.queue_manager import QueueManager
from pipeline.youtube_uploader import YouTubeUploader
from pipeline.dependency_manager import ensure_dependencies
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%H:%M:%S"
)
logger = logging.getLogger("YTFactory")
@dataclass
class FactoryConfig:
topic: str = ""
duration_seconds: int = 180
output_dir: str = "./output"
temp_dir: str = "./temp"
visual_strategy: str = "hybrid"
clip_width: int = 768
clip_height: int = 432
voices: Dict[str, str] = field(default_factory=lambda: {"en": "af_heart", "es": "ef_dora"})
thumbnail_count: int = 3
music_mood: str = "cinematic"
pexels_api_key: Optional[str] = None
pixabay_api_key: Optional[str] = None
poll_interval_seconds: int = 30
auto_trending_when_empty: bool = True
trending_niche: str = ""
max_daily_videos: int = 12
youtube_client_secrets: str = "client_secrets.json"
youtube_token_file: str = "youtube_token.json"
youtube_default_privacy: str = "private"
youtube_auto_upload: bool = False
def format_timestamp(seconds: float) -> str:
"""Format seconds into MM:SS format for YouTube chapter markers."""
m = int(seconds) // 60
s = int(seconds) % 60
return f"{m:02d}:{s:02d}"
class YouTubeFactory:
def __init__(self, config_path: str = "factory_config.yaml", check_deps: bool = True):
if check_deps:
ensure_dependencies(auto_install=True)
self.config_path = config_path
self.raw_config = self._load_config(config_path)
self.cfg = self._parse_config(self.raw_config)
self.pcs: Dict[str, RemotePC] = {}
for name, data in self.raw_config.get("pcs", {}).items():
self.pcs[name] = RemotePC(name, data["host"], data["ssh_user"], data.get("services", {}))
self.queue = QueueManager(db_path=os.path.join(self.cfg.output_dir, "factory_queue.db"))
self.uploader = YouTubeUploader(
client_secrets_file=self.cfg.youtube_client_secrets,
token_file=self.cfg.youtube_token_file,
default_privacy=self.cfg.youtube_default_privacy
)
os.makedirs(self.cfg.output_dir, exist_ok=True)
os.makedirs(self.cfg.temp_dir, exist_ok=True)
def _load_config(self, path: str) -> Dict[str, Any]:
if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}")
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def _parse_config(self, raw: Dict[str, Any]) -> FactoryConfig:
p_cfg = raw.get("pipeline", {})
d_cfg = raw.get("daemon", {})
y_cfg = raw.get("youtube", {})
return FactoryConfig(
topic=p_cfg.get("topic", ""),
duration_seconds=p_cfg.get("duration_seconds", 180),
output_dir=p_cfg.get("output_dir", "./output"),
temp_dir=p_cfg.get("temp_dir", "./temp"),
visual_strategy=p_cfg.get("visual_strategy", "hybrid"),
clip_width=p_cfg.get("clip_width", 768),
clip_height=p_cfg.get("clip_height", 432),
voices=p_cfg.get("voices", {"en": "af_heart", "es": "ef_dora"}),
thumbnail_count=p_cfg.get("thumbnail_count", 3),
music_mood=p_cfg.get("music_mood", "cinematic"),
pexels_api_key=p_cfg.get("pexels_api_key") or None,
pixabay_api_key=p_cfg.get("pixabay_api_key") or None,
poll_interval_seconds=d_cfg.get("poll_interval_seconds", 30),
auto_trending_when_empty=d_cfg.get("auto_trending_when_empty", True),
trending_niche=d_cfg.get("trending_niche", ""),
max_daily_videos=d_cfg.get("max_daily_videos", 12),
youtube_client_secrets=y_cfg.get("client_secrets_file", "client_secrets.json"),
youtube_token_file=y_cfg.get("token_file", "youtube_token.json"),
youtube_default_privacy=y_cfg.get("default_privacy", "private"),
youtube_auto_upload=y_cfg.get("auto_upload", False)
)
def health(self):
"""Check connection and API service availability on all 3 PCs."""
logger.info("=" * 65)
logger.info("📊 3-PC YouTube Factory Health Check")
logger.info("=" * 65)
all_ok = True
for name, pc in self.pcs.items():
logger.info(f"Checking PC: {name.upper()} ({pc.host})...")
for svc_name, svc_info in pc.services.items():
port = svc_info.get("port", 0)
if port > 0:
status = pc.check(port)
icon = "✅ UP " if status else "❌ DOWN"
logger.info(f" {icon} {name}.{svc_name:<12} on port :{port}")
if not status:
all_ok = False
logger.info("=" * 65)
if all_ok:
logger.info("🎉 All AI services are responding and ready.")
else:
logger.warning("⚠️ Some services are currently unreachable. Review above.")
def run_video_pipeline(self, topic: str, duration: Optional[int] = None, dry_run: bool = False) -> Dict[str, Any]:
"""Execute the end-to-end video production pipeline for a single topic."""
target_dur = duration or self.cfg.duration_seconds
clean_topic_slug = re.sub(r'[^A-Za-z0-9_]', '_', topic[:25]).strip('_')
job_id = f"{int(time.time())}_{clean_topic_slug}"
job_dir = os.path.join(self.cfg.temp_dir, job_id)
os.makedirs(job_dir, exist_ok=True)
logger.info("" * 70)
logger.info(f"🎬 NEW VIDEO JOB: \"{topic}\"")
logger.info(f"⏱️ TARGET DURATION: {target_dur}s ({target_dur//60}m {target_dur%60}s)")
logger.info(f"📁 JOB ID: {job_id}")
logger.info("" * 70)
timings = {}
def time_stage(name, fn):
t0 = time.time()
res = fn()
elapsed = round(time.time() - t0, 1)
timings[name] = elapsed
logger.info(f"[{name}] Completed in {elapsed}s")
return res
# ── Step 1: Script Generation & Translation (AMDLLM) ──
script_file = os.path.join(job_dir, "script.json")
if os.path.exists(script_file):
logger.info("[SCRIPT] Loading existing script checkpoint...")
with open(script_file, "r", encoding="utf-8") as f:
script = json.load(f)
else:
script_gen = ScriptGen(self.pcs)
script = time_stage("SCRIPT", lambda: script_gen.generate(topic, target_dur))
with open(script_file, "w", encoding="utf-8") as f:
json.dump(script, f, indent=2, ensure_ascii=False)
if dry_run:
logger.info("[DRY-RUN] Script and metadata generated successfully. Stopping before GPU rendering.")
return {"job_id": job_id, "script": script}
# ── Step 2: Dual Voiceover Synthesis (IntelLLM) ──
tts_gen = TTSGen(self.pcs, self.cfg.voices)
tts_en = time_stage("TTS:EN", lambda: tts_gen.generate(script, job_dir, "en"))
tts_es = time_stage("TTS:ES", lambda: tts_gen.generate(script, job_dir, "es"))
# ── Step 3: Procedural Background Music Generation ──
music_gen = MusicGen()
music_path = os.path.join(job_dir, "background_music.wav")
max_voice_dur = max(tts_en["total_duration"], tts_es["total_duration"])
time_stage("MUSIC", lambda: music_gen.generate(max_voice_dur + 5.0, music_path, mood=self.cfg.music_mood))
# ── Step 4: Visual Generation & Stock Media (NvidiaLLM & Web) ──
visual_gen = VisualGen(self.pcs, self.cfg)
clips = time_stage("VISUAL", lambda: visual_gen.generate(script, job_dir, segment_timings=tts_en["segments"]))
# ── Step 5: Subtitle Transcription via Faster-Whisper (IntelLLM) ──
sub_gen = SubGen(self.pcs)
subs_en = time_stage("SUBS:EN", lambda: sub_gen.generate(tts_en["master_wav"], job_dir, "en"))
subs_es = time_stage("SUBS:ES", lambda: sub_gen.generate(tts_es["master_wav"], job_dir, "es"))
# ── Step 6: A/B Thumbnail Generation (NvidiaLLM) ──
thumb_gen = ThumbnailGen(self.pcs, self.cfg)
thumbs = time_stage("THUMBNAILS", lambda: thumb_gen.generate(script, topic, job_dir))
# ── Step 7: Final Master Video Assembly with Audio Ducking (IntelLLM) ──
assembler = Assembler(self.pcs)
out_prefix = os.path.join(self.cfg.output_dir, job_id)
rendered_videos = time_stage("ASSEMBLY", lambda: assembler.assemble(
clips=clips,
audio_map={"en": tts_en["master_wav"], "es": tts_es["master_wav"]},
music_path=music_path,
subs_map={"en": subs_en, "es": subs_es},
script=script,
out_prefix=out_prefix
))
# ── Step 8: Package Ready-to-Upload Bundles & Copy Assets ──
# Copy thumbnails to output folder
packaged_thumbs = {"en": [], "es": [], "clean": []}
for lang_key in ("en", "es", "clean"):
for t_file in thumbs.get(lang_key, []):
dest = f"{out_prefix}_{os.path.basename(t_file)}"
subprocess.run(["cp", t_file, dest], check=True)
packaged_thumbs[lang_key].append(dest)
# Copy standalone subtitle tracks to output
packaged_subs = {}
for l_code, s_file in [("en", subs_en), ("es", subs_es)]:
s_dest = f"{out_prefix}_subtitles_{l_code}.srt"
subprocess.run(["cp", s_file, s_dest], check=True)
packaged_subs[l_code] = s_dest
# Build Formatted Chapters for Descriptions
chapters_en_txt = "\n".join([
f"{format_timestamp(s['start'])} - {s['chapter_title']}"
for s in tts_en["segments"]
])
chapters_es_txt = "\n".join([
f"{format_timestamp(s['start'])} - {s.get('chapter_title', f'Parte {i+1}')}"
for i, s in enumerate(tts_es["segments"])
])
desc_en = f"{script.get('description', '')}\n\nTIMESTAMPS / CHAPTERS:\n{chapters_en_txt}\n\n#Documentary #History #Science"
desc_es = f"{script.get('description_es', script.get('description', ''))}\n\nCAPÍTULOS:\n{chapters_es_txt}\n\n#Documental #Historia #Ciencia"
# Master Metadata Manifest
bundle_meta = {
"job_id": job_id,
"topic": topic,
"titles": script.get("titles", [topic]),
"titles_es": script.get("titles_es", [topic]),
"description": desc_en,
"description_es": desc_es,
"tags": script.get("tags", []),
"chapters_en": tts_en["segments"],
"chapters_es": tts_es["segments"],
"clips": clips,
"files": rendered_videos,
"subtitles": packaged_subs,
"thumbnails": packaged_thumbs,
"timings": timings,
"created_at": time.time()
}
meta_json_path = f"{out_prefix}_meta.json"
with open(meta_json_path, "w", encoding="utf-8") as f:
json.dump(bundle_meta, f, indent=2, ensure_ascii=False)
# Upload Text Files (Copy-paste friendly for YouTube Studio)
with open(f"{out_prefix}_upload_EN.txt", "w", encoding="utf-8") as f:
f.write("=" * 60 + "\n")
f.write(f"YOUTUBE UPLOAD BUNDLE — ENGLISH (Topic: {topic})\n")
f.write("=" * 60 + "\n\n")
f.write("TITLES FOR A/B TESTING:\n")
for i, t in enumerate(script.get("titles", [])):
f.write(f" {chr(65+i)}. {t}\n")
f.write(f"\nDESCRIPTION & CHAPTERS:\n{desc_en}\n\n")
f.write(f"TAGS:\n{', '.join(script.get('tags', []))}\n\n")
f.write(f"VIDEO FILE: {rendered_videos.get('en', '')}\n")
f.write(f"SUBTITLES: {packaged_subs.get('en', '')}\n")
f.write("THUMBNAILS:\n")
for t in packaged_thumbs.get("en", []):
f.write(f" - {t}\n")
with open(f"{out_prefix}_upload_ES.txt", "w", encoding="utf-8") as f:
f.write("=" * 60 + "\n")
f.write(f"PAQUETE DE SUBIDA A YOUTUBE — ESPAÑOL (Tema: {topic})\n")
f.write("=" * 60 + "\n\n")
f.write("TÍTULOS PARA PRUEBA A/B:\n")
for i, t in enumerate(script.get("titles_es", [])):
f.write(f" {chr(65+i)}. {t}\n")
f.write(f"\nDESCRIPCIÓN Y CAPÍTULOS:\n{desc_es}\n\n")
f.write(f"ETIQUETAS / TAGS:\n{', '.join(script.get('tags', []))}\n\n")
f.write(f"ARCHIVO DE VIDEO: {rendered_videos.get('es', '')}\n")
f.write(f"SUBTÍTULOS: {packaged_subs.get('es', '')}\n")
f.write("MINIATURAS:\n")
for t in packaged_thumbs.get("es", []):
f.write(f" - {t}\n")
logger.info("" * 70)
logger.info("🎉 VIDEO GENERATION COMPLETE!")
logger.info(f"📹 English Video: {rendered_videos.get('en')}")
logger.info(f"📹 Spanish Video: {rendered_videos.get('es')}")
logger.info(f"🖼️ Thumbnails: {len(packaged_thumbs.get('en', []))} A/B variants")
logger.info(f"📄 Manifest: {meta_json_path}")
logger.info("" * 70)
# Optional Auto-Upload
if self.cfg.youtube_auto_upload and self.uploader.is_configured():
try:
logger.info("[YOUTUBE] Auto-upload enabled. Publishing to YouTube...")
self.uploader.upload_bundle(meta_json_path, lang="en")
except Exception as e:
logger.error(f"[YOUTUBE] Auto-upload failed: {e}")
return bundle_meta
def run_daemon(self):
"""24/7 Autonomous Daemon Worker Loop."""
logger.info("=" * 65)
logger.info("🤖 YouTube Factory 24/7 Daemon Worker Started")
logger.info(f" Polling Interval: {self.cfg.poll_interval_seconds}s")
logger.info(f" Auto-Trending: {self.cfg.auto_trending_when_empty}")
logger.info("=" * 65)
trending_fetcher = TrendingFetcher(self.pcs.get("amdllm"))
while True:
try:
job = self.queue.get_next_job()
if not job:
if self.cfg.auto_trending_when_empty:
logger.info("[DAEMON] Queue is empty. Fetching next trending viral topic...")
trending_topics = trending_fetcher.get_trending_topics(
niche=self.cfg.trending_niche,
count=1
)
if trending_topics:
new_topic = trending_topics[0]
logger.info(f"[DAEMON] Enqueuing trending topic: '{new_topic}'")
self.queue.add_job(new_topic)
continue
logger.debug(f"[DAEMON] Waiting {self.cfg.poll_interval_seconds}s for jobs...")
time.sleep(self.cfg.poll_interval_seconds)
continue
# Process active job
job_id = job["job_id"]
topic = job["topic"]
dur = job.get("duration") or self.cfg.duration_seconds
logger.info(f"[DAEMON] Starting Job: {job_id} -> '{topic}'")
self.queue.update_progress(job_id, "PROCESSING")
try:
result = self.run_video_pipeline(topic, duration=dur)
self.queue.mark_completed(job_id, result)
except Exception as e:
logger.error(f"[DAEMON] Error processing job {job_id}: {e}", exc_info=True)
self.queue.mark_failed(job_id, str(e))
except KeyboardInterrupt:
logger.info("\n[DAEMON] Shutting down 24/7 worker...")
break
except Exception as e:
logger.error(f"[DAEMON] Unexpected worker error: {e}", exc_info=True)
time.sleep(10)
def main():
parser = argparse.ArgumentParser(description="YouTube Factory — 3-PC Faceless Video Pipeline")
parser.add_argument("--topic", "-t", help="Single video topic to generate")
parser.add_argument("--duration", "-d", type=int, help="Target duration in seconds")
parser.add_argument("--batch", "-b", nargs="+", help="List of video topics to process sequentially")
parser.add_argument("--batch-file", "-f", help="Text file containing one topic per line")
parser.add_argument("--trending", action="store_true", help="Fetch real-time trending topics and generate videos")
parser.add_argument("--trending-niche", help="Trending niche (history, space, science, tech, mysteries, business)")
parser.add_argument("--trending-count", type=int, default=3, help="Number of trending topics to fetch/queue")
parser.add_argument("--daemon", action="store_true", help="Start 24/7 continuous autonomous generation worker")
parser.add_argument("--queue-add", help="Enqueue a new topic into the background queue")
parser.add_argument("--queue-list", action="store_true", help="Display all pending and completed jobs in queue")
parser.add_argument("--queue-retry", action="store_true", help="Reset all failed jobs back to queued state")
parser.add_argument("--health", "-H", action="store_true", help="Check status of all 3 PCs and their AI services")
parser.add_argument("--setup", action="store_true", help="Check and automatically install all dependencies across environment")
parser.add_argument("--check-deps", action="store_true", help="Verify Python packages, system tools, and SSH connections")
parser.add_argument("--dry-run", action="store_true", help="Generate script and metadata without running heavy GPU video rendering")
parser.add_argument("--upload", help="Upload a completed video bundle meta.json to YouTube")
parser.add_argument("--config", "-c", default="factory_config.yaml", help="Path to custom config YAML")
args = parser.parse_args()
if args.setup or args.check_deps:
print("\n🔍 Checking YouTube Factory Dependencies...\n")
ok = ensure_dependencies(auto_install=True, check_remote=True)
if ok:
print("\n✅ All dependencies and remote connections are fully configured!\n")
else:
print("\n⚠️ Some dependencies or remote connections require attention.\n")
sys.exit(0 if ok else 1)
factory = YouTubeFactory(args.config)
if args.health:
factory.health()
elif args.upload:
if not factory.uploader.is_configured():
logger.error("YouTube API credentials not configured. Please place client_secrets.json in this directory.")
sys.exit(1)
factory.uploader.upload_bundle(args.upload)
elif args.queue_add:
factory.queue.add_job(args.queue_add, duration=args.duration)
elif args.queue_list:
jobs = factory.queue.list_jobs()
print("\n" + "=" * 80)
print(f"{'JOB ID':<20} | {'STATUS':<12} | {'STEP':<12} | {'TOPIC'}")
print("-" * 80)
for j in jobs:
print(f"{j['job_id']:<20} | {j['status']:<12} | {j['progress_step']:<12} | {j['topic'][:40]}")
print("=" * 80 + "\n")
elif args.queue_retry:
retried = factory.queue.retry_failed()
logger.info(f"Retried {retried} failed jobs.")
elif args.daemon:
factory.run_daemon()
elif args.trending:
fetcher = TrendingFetcher(factory.pcs.get("amdllm"))
topics = fetcher.get_trending_topics(niche=args.trending_niche, count=args.trending_count)
logger.info(f"Discovered {len(topics)} trending topics: {topics}")
for t in topics:
factory.run_video_pipeline(t, duration=args.duration, dry_run=args.dry_run)
elif args.batch_file:
if not os.path.exists(args.batch_file):
logger.error(f"Batch file not found: {args.batch_file}")
sys.exit(1)
with open(args.batch_file, "r") as f:
topics = [line.strip() for line in f if line.strip() and not line.startswith("#")]
for t in topics:
factory.run_video_pipeline(t, duration=args.duration, dry_run=args.dry_run)
elif args.batch:
for t in args.batch:
factory.run_video_pipeline(t, duration=args.duration, dry_run=args.dry_run)
elif args.topic:
factory.run_video_pipeline(args.topic, duration=args.duration, dry_run=args.dry_run)
else:
# Interactive mode
topic = input("Enter video topic: ").strip()
if topic:
factory.run_video_pipeline(topic, duration=args.duration, dry_run=args.dry_run)
if __name__ == "__main__":
main()