You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
RedditVideoMakerBot/manual_main.py

450 lines
15 KiB

#!/usr/bin/env python
"""
Manual Screenshot → Video Pipeline — Entry Point
Create videos from manually captured screenshots and text files,
without requiring any social media API access.
Supports screenshots from: Reddit, Threads (Meta), X (Twitter), or any platform.
Usage:
python manual_main.py init <post_id> [--platform reddit|threads|x|other]
python manual_main.py render <post_id>
python manual_main.py render --all
python manual_main.py list
"""
import argparse
import json
import sys
from os.path import exists
from pathlib import Path
import toml
from utils import settings
from utils.console import print_markdown, print_step, print_substep
from utils.ffmpeg_install import ffmpeg_install
from manual.scanner import PostScanner
from manual.tts_processor import ManualTTSProcessor
from manual.video_builder import ManualVideoBuilder
__VERSION__ = "1.0.0"
# ────────────────────────────────────────────────────────────────
# Configuration
# ────────────────────────────────────────────────────────────────
# Default config for manual pipeline (used when [manual] section not in config.toml)
MANUAL_DEFAULTS = {
"input_dir": "manual_posts",
"output_dir": "manual_results",
"encoder": "libx264",
"resolution_w": 1080,
"resolution_h": 1920,
"opacity": 1,
"background_video": "random",
"background_audio": "random",
"background_video_dir": "assets/backgrounds/video",
"background_audio_dir": "assets/backgrounds/audio",
"background_audio_volume": 0.1,
"max_video_length": 120,
"screenshot_width_percent": 85,
"watermark_enabled": True,
"watermark_path": "assets/backgrounds/transparent-bg.png",
}
# Full default settings.config that TTS engines and shared modules expect.
# This ensures the manual flow works even if config.toml is empty or missing sections.
_BASE_SETTINGS_DEFAULTS = {
"reddit": {
"creds": {
"client_id": "",
"client_secret": "",
"username": "",
"password": "",
"2fa": False,
},
"thread": {
"subreddit": "",
"post_id": "",
"max_comment_length": 500,
"min_comment_length": 1,
"post_lang": "vi",
"min_comments": 20,
"blocked_words": "",
},
},
"ai": {
"ai_similarity_enabled": False,
"ai_similarity_keywords": "",
},
"settings": {
"allow_nsfw": False,
"theme": "dark",
"times_to_run": 1,
"opacity": 0.9,
"storymode": False,
"storymodemethod": 1,
"storymode_max_length": 1000,
"resolution_w": 1080,
"resolution_h": 1920,
"zoom": 1,
"channel_name": "Reddit Tales",
"background": {
"background_video": "minecraft",
"background_audio": "lofi",
"background_audio_volume": 0.1,
"enable_extra_audio": False,
"background_thumbnail": False,
"background_thumbnail_font_family": "arial",
"background_thumbnail_font_size": 96,
"background_thumbnail_font_color": "255,255,255",
},
"tts": {
"voice_choice": "crikk",
"random_voice": False,
"elevenlabs_voice_name": "Bella",
"elevenlabs_api_key": "",
"aws_polly_voice": "Matthew",
"streamlabs_polly_voice": "Matthew",
"tiktok_voice": "en_us_001",
"tiktok_sessionid": "",
"python_voice": "1",
"py_voice_num": "2",
"silence_duration": 0.3,
"no_emojis": False,
"openai_api_url": "https://api.openai.com/v1/",
"openai_api_key": "",
"openai_voice_name": "alloy",
"openai_model": "tts-1",
"ohfreeme_lang": "vi",
"ohfreeme_gender": "random",
"ohfreeme_rate": 1,
"ohfreeme_pitch": 0,
"ohfreeme_enhance": False,
},
},
}
def _deep_merge(base: dict, override: dict) -> dict:
"""Deep merge two dicts. Values in 'override' take priority."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config() -> dict:
"""Load config and set up settings.config for TTS engines and backgrounds.
Strategy:
1. Start with full default config (so TTS engines always have what they need)
2. If config.toml exists and has content, deep-merge on top of defaults
3. Extract [manual] section for manual-specific settings
4. Set settings.config globally so shared modules (TTS, background, etc.) work
Returns:
dict: Manual-specific config merged with defaults
"""
# Start with complete defaults
config = _deep_merge({}, _BASE_SETTINGS_DEFAULTS)
# Try to load config.toml and merge on top
config_path = Path("config.toml")
if config_path.exists():
try:
file_config = toml.load(str(config_path))
if file_config: # Not empty
config = _deep_merge(config, file_config)
print_substep("Loaded config from config.toml", style="dim")
except Exception as e:
print_substep(f"Warning: Could not parse config.toml: {e}", style="yellow")
else:
print_substep(
"config.toml not found — using built-in defaults. "
"TTS will use GoogleTranslate (no API key needed).",
style="yellow",
)
# Set global settings.config so TTS engines and shared modules work
settings.config = config
# Build manual-specific config: defaults + [manual] section from config.toml
manual_config = {**MANUAL_DEFAULTS}
if "manual" in config:
manual_config.update(config["manual"])
return manual_config
# ────────────────────────────────────────────────────────────────
# Commands
# ────────────────────────────────────────────────────────────────
def cmd_init(args, manual_config):
"""Create a new post folder with template files."""
from manual.scanner import create_post_folder
post_id = args.post_id
platform = getattr(args, "platform", "reddit")
input_dir = manual_config["input_dir"]
post_dir = create_post_folder(input_dir, post_id, platform)
print_markdown(f"### Post folder created: `{post_dir}`")
def cmd_render(args, manual_config):
"""Render one or all posts into videos."""
scanner = PostScanner(input_dir=manual_config["input_dir"])
if args.all:
# Render all ready posts
posts = scanner.scan_all()
if not posts:
print_substep("No valid posts found in the input directory.", style="red")
return
# Filter out already rendered
posts_to_render = []
for post in posts:
if _is_already_done(post["post_id"]):
print_substep(f"{post['post_id']} — already rendered, skipping", style="blue")
else:
posts_to_render.append(post)
if not posts_to_render:
print_substep("All posts have already been rendered!", style="green")
return
print_step(f"📋 Rendering {len(posts_to_render)} posts...")
for i, post in enumerate(posts_to_render):
print_markdown(
f"### [{i+1}/{len(posts_to_render)}] Rendering: {post['post_id']}"
)
_render_single(post, manual_config)
else:
# Render single post
if not args.post_id:
print_substep("Please specify a post_id or use --all", style="red")
return
post = scanner.scan_one(args.post_id)
if post is None:
return # Error already printed by scanner
if _is_already_done(post["post_id"]) and not args.force:
print_substep(
f"Post '{post['post_id']}' already rendered. Use --force to re-render.",
style="yellow",
)
return
_render_single(post, manual_config)
def _render_single(post_object: dict, manual_config: dict):
"""Render a single post into a video.
Pipeline:
1. TTS: Convert text → MP3 audio files
2. Video: Assemble screenshots + audio + background → MP4
"""
post_id = post_object["post_id"]
print_step(f"🚀 Starting render for: {post_id}")
# Step 1: TTS
max_length = manual_config.get("max_video_length", 120)
tts = ManualTTSProcessor(post_object, max_length=max_length)
post_object = tts.process()
# Check if we have audio
clips_with_audio = [s for s in post_object["screenshots"] if s.get("audio_path")]
if not clips_with_audio:
print_substep("No audio generated. Check text files.", style="red")
return
# Step 2: Video build
builder = ManualVideoBuilder(post_object, manual_config)
output_path = builder.build()
if output_path:
print_markdown(f"### ✅ Video saved: `{output_path}`")
else:
print_substep("Video rendering failed.", style="red")
def cmd_list(args, manual_config):
"""List all posts and their status."""
from manual.scanner import PostScanner
scanner = PostScanner(input_dir=manual_config["input_dir"])
statuses = scanner.list_status()
if not statuses:
print_substep(
f"No posts found in '{manual_config['input_dir']}/'. "
f"Run 'python manual_main.py init <post_id>' to create one.",
style="yellow",
)
return
# Status emoji map
status_icons = {
"ready": "",
"incomplete": "⚠️",
"empty": "",
}
print_step("📋 Manual Posts Status")
print()
for s in statuses:
icon = status_icons.get(s["status"], "")
rendered = "🎬" if _is_already_done(s["post_id"]) else " "
print_substep(
f" {icon} {rendered} {s['post_id']:30s} "
f"| {s['num_images']} 🖼️ {s.get('num_audios', 0)} 🎵 {s['num_texts']} 📝 "
f"| {s['status']}",
style="bold" if s["status"] == "ready" else "",
)
if s["errors"]:
for err in s["errors"]:
print_substep(f"{err}", style="red")
print()
ready_count = sum(1 for s in statuses if s["status"] == "ready")
rendered_count = sum(1 for s in statuses if _is_already_done(s["post_id"]))
print_substep(
f" Total: {len(statuses)} posts | "
f"{ready_count} ready | "
f"{rendered_count} rendered",
style="bold cyan",
)
def _is_already_done(post_id: str) -> bool:
"""Check if a post has already been rendered (shared videos.json)."""
videos_path = "./video_creation/data/videos.json"
if not exists(videos_path):
return False
try:
with open(videos_path, "r", encoding="utf-8") as f:
done_videos = json.load(f)
return any(v.get("id") == post_id for v in done_videos)
except (json.JSONDecodeError, IOError):
return False
# ────────────────────────────────────────────────────────────────
# CLI
# ────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="manual_main.py",
description="Manual Screenshot → Video Pipeline. "
"Create videos from screenshots captured from Reddit, Threads, X, or any platform.",
)
parser.add_argument(
"--version", action="version", version=f"%(prog)s {__VERSION__}"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# init command
init_parser = subparsers.add_parser("init", help="Create a new post folder with template files")
init_parser.add_argument("post_id", type=str, help="Name/ID for the post folder")
init_parser.add_argument(
"--platform",
type=str,
default="reddit",
choices=["reddit", "threads", "x", "other"],
help="Source platform (default: reddit)",
)
# render command
render_parser = subparsers.add_parser("render", help="Render post(s) into video(s)")
render_parser.add_argument(
"post_id", type=str, nargs="?", default=None, help="Post ID to render"
)
render_parser.add_argument(
"--all", action="store_true", help="Render all unrendered posts"
)
render_parser.add_argument(
"--force", action="store_true", help="Re-render even if already done"
)
render_parser.add_argument(
"--lang", type=str, default="vi", help="Override TTS language (e.g. vi, en)"
)
# list command
subparsers.add_parser("list", help="List all posts and their status")
return parser
def main():
print(
"""
╔══════════════════════════════════════════════════════════╗
║ Manual Screenshot → Video Pipeline v1.0.0 ║
║ Supports: Reddit • Threads • X • Any Platform ║
╚══════════════════════════════════════════════════════════╝
"""
)
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
# Check Python version
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
print("This program requires Python 3.10, 3.11, or 3.12.")
sys.exit(1)
# Check FFmpeg
ffmpeg_install()
# Load config
manual_config = load_config()
# Create input directory if it doesn't exist
input_dir = Path(manual_config["input_dir"])
input_dir.mkdir(parents=True, exist_ok=True)
# Dispatch command
commands = {
"init": cmd_init,
"render": cmd_render,
"list": cmd_list,
}
cmd_func = commands.get(args.command)
if cmd_func:
try:
cmd_func(args, manual_config)
except KeyboardInterrupt:
print("\nInterrupted by user.")
sys.exit(0)
except Exception as e:
print_substep(f"Error: {e}", style="red")
raise
else:
parser.print_help()
if __name__ == "__main__":
main()