diff --git a/README.md b/README.md index 8042755..6b74289 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,134 @@ -# Reddit Video Maker Bot 🎥 - -All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨. - -Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca) - - - - - - - - - - -## Video Explainer - -[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png) -](https://www.youtube.com/watch?v=3gjcY_00U1w) - -## Motivation 🤔 - -These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort. -The only original thing being done is the editing and gathering of all materials... - -... but what if we can automate that process? 🤔 - -## Disclaimers 🚨 - -- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that +# 🇻🇳 Threads Video Maker Bot - Phiên Bản Việt Nam 🎥 + +Tạo video tự động từ nội dung **Threads (Meta)** và đăng lên **TikTok**, **YouTube**, **Facebook**. + +Được phát triển dựa trên nền tảng Reddit Video Maker Bot, tối ưu hóa cho thị trường Việt Nam. + +## Tính Năng Chính ✨ + +- 📱 **Threads Integration**: Lấy nội dung tự động từ Threads (Meta) thay vì Reddit +- 🎙️ **TTS Tiếng Việt**: Hỗ trợ đọc tiếng Việt qua Google Translate TTS, OpenAI, và nhiều engine khác +- 📤 **Auto-Upload**: Tự động đăng video lên TikTok, YouTube, Facebook +- ⏰ **Lên Lịch Tự Động**: Cron-based scheduling với múi giờ Việt Nam +- 🎬 **Video Chất Lượng**: Background gaming, nhạc nền lofi, subtitle overlay +- 🔄 **Pipeline Hoàn Chỉnh**: Từ lấy nội dung → TTS → Screenshot → Video → Upload + +## Cài Đặt 🛠️ + +### Yêu Cầu +- Python 3.10, 3.11 hoặc 3.12 +- FFmpeg +- Tài khoản Threads Developer (Meta) + +### Bước 1: Clone và cài đặt +```bash +git clone https://github.com/thaitien280401-stack/RedditVideoMakerBot.git +cd RedditVideoMakerBot +pip install -r requirements.txt +``` + +### Bước 2: Cấu hình +Chạy lần đầu để tạo file `config.toml`: +```bash +python main.py +``` + +Hoặc copy từ template: +```bash +cp utils/.config.template.toml config.toml +``` + +### Bước 3: Điền thông tin API +Chỉnh sửa `config.toml`: + +```toml +[threads.creds] +access_token = "YOUR_THREADS_ACCESS_TOKEN" +user_id = "YOUR_THREADS_USER_ID" + +[settings.tts] +voice_choice = "googletranslate" # Hỗ trợ tiếng Việt tốt nhất +``` + +## Sử Dụng 🚀 + +### Chế độ Manual (Mặc định) +```bash +python main.py +``` + +### Chế độ Auto (Tạo + Upload) +```bash +python main.py --mode auto +``` + +### Chế độ Scheduled (Lên lịch tự động) +```bash +python main.py --mode scheduled +``` + +### Legacy Reddit Mode +```bash +python main.py --reddit +``` + +## Cấu Hình Upload 📤 + +### YouTube +```toml +[uploaders.youtube] +enabled = true +client_id = "YOUR_GOOGLE_CLIENT_ID" +client_secret = "YOUR_GOOGLE_CLIENT_SECRET" +refresh_token = "YOUR_GOOGLE_REFRESH_TOKEN" +``` + +### TikTok +```toml +[uploaders.tiktok] +enabled = true +client_key = "YOUR_TIKTOK_CLIENT_KEY" +client_secret = "YOUR_TIKTOK_CLIENT_SECRET" +refresh_token = "YOUR_TIKTOK_REFRESH_TOKEN" +``` + +### Facebook +```toml +[uploaders.facebook] +enabled = true +page_id = "YOUR_FACEBOOK_PAGE_ID" +access_token = "YOUR_FACEBOOK_PAGE_ACCESS_TOKEN" +``` + +## Cấu Hình Scheduler ⏰ + +```toml +[scheduler] +enabled = true +cron = "0 8,14,20 * * *" # Chạy lúc 8h, 14h, 20h hàng ngày +timezone = "Asia/Ho_Chi_Minh" +max_videos_per_day = 4 +``` + +## Kiến Trúc Hệ Thống 🏗️ + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Threads API │────▶│ Video Engine │────▶│ Upload Manager │ +│ (Meta Graph) │ │ TTS + FFmpeg │ │ YT/TT/FB APIs │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ + threads/ video_creation/ uploaders/ + threads_client.py threads_screenshot.py youtube_uploader.py + voices.py tiktok_uploader.py + final_video.py facebook_uploader.py + upload_manager.py + │ + ▼ + scheduler/ + pipeline.py (APScheduler) +``` you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues. ## Requirements diff --git a/TTS/GTTS.py b/TTS/GTTS.py index 2e2d530..8fbaada 100644 --- a/TTS/GTTS.py +++ b/TTS/GTTS.py @@ -11,9 +11,15 @@ class GTTS: self.voices = [] def run(self, text, filepath, random_voice: bool = False): + # Support both Threads and Reddit config for language + lang = "en" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") or lang + elif "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") or lang tts = gTTS( text=text, - lang=settings.config["reddit"]["thread"]["post_lang"] or "en", + lang=lang, slow=False, ) tts.save(filepath) diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 1026a6d..fd0f9da 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -179,7 +179,12 @@ class TTSEngine: def process_text(text: str, clean: bool = True): - lang = settings.config["reddit"]["thread"]["post_lang"] + # Support both Threads and Reddit config + lang = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") + if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") new_text = sanitize_text(text) if clean else text if lang: print_substep("Translating Text...") diff --git a/main.py b/main.py index 742fedf..46a7f90 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,25 @@ #!/usr/bin/env python +""" +Threads Video Maker Bot - Tạo video tự động từ Threads (Meta) cho thị trường Việt Nam. + +Hỗ trợ: +- Lấy nội dung từ Threads (Meta) thay vì Reddit +- TTS tiếng Việt (Google Translate, OpenAI, v.v.) +- Tự động upload lên TikTok, YouTube, Facebook +- Lên lịch tạo video tự động + +Modes: +- manual: Tạo video thủ công (mặc định) +- auto: Tạo video và tự động upload +- scheduled: Lên lịch tạo video tự động + +Usage: + python main.py # Chế độ manual + python main.py --mode auto # Tạo + upload + python main.py --mode scheduled # Lên lịch tự động + python main.py --reddit # Legacy Reddit mode +""" + import math import sys from os import name @@ -6,88 +27,155 @@ from pathlib import Path from subprocess import Popen from typing import Dict, NoReturn -from prawcore import ResponseException - -from reddit.subreddit import get_subreddit_threads from utils import settings from utils.cleanup import cleanup from utils.console import print_markdown, print_step, print_substep from utils.ffmpeg_install import ffmpeg_install from utils.id import extract_id -from utils.version import checkversion -from video_creation.background import ( - chop_background, - download_background_audio, - download_background_video, - get_background_config, -) -from video_creation.final_video import make_final_video -from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts -from video_creation.voices import save_text_to_mp3 -__VERSION__ = "3.4.0" +__VERSION__ = "4.0.0" print( """ -██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ -██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ -██████╔╝█████╗ ██║ ██║██║ ██║██║ ██║ ██║ ██║██║██║ ██║█████╗ ██║ ██║ ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ -██╔══██╗██╔══╝ ██║ ██║██║ ██║██║ ██║ ╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ -██║ ██║███████╗██████╔╝██████╔╝██║ ██║ ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝ ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ -╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ +████████╗██╗ ██╗██████╗ ███████╗ █████╗ ██████╗ ███████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ +╚══██╔══╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ + ██║ ███████║██████╔╝█████╗ ███████║██║ ██║███████╗ ██║ ██║██║██║ ██║█████╗ ██║ ██║ + ██║ ██╔══██║██╔══██╗██╔══╝ ██╔══██║██║ ██║╚════██║ ╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║ ██║ + ██║ ██║ ██║██║ ██║███████╗██║ ██║██████╔╝███████║ ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ + + ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ 🇻🇳 VIETNAM EDITION + ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ + ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ Powered by Threads (Meta) + ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ Auto-post: TikTok | YouTube | Facebook + ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ + ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ """ ) print_markdown( - "### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/" + "### 🇻🇳 Threads Video Maker Bot - Phiên bản Việt Nam\n" + "Tạo video tự động từ nội dung Threads và đăng lên TikTok, YouTube, Facebook.\n" + "Tài liệu: https://github.com/thaitien280401-stack/RedditVideoMakerBot" ) -checkversion(__VERSION__) -reddit_id: str -reddit_object: Dict[str, str | list] +thread_id: str +thread_object: Dict[str, str | list] -def main(POST_ID=None) -> None: - global reddit_id, reddit_object - reddit_object = get_subreddit_threads(POST_ID) - reddit_id = extract_id(reddit_object) - print_substep(f"Thread ID is {reddit_id}", style="bold blue") - length, number_of_comments = save_text_to_mp3(reddit_object) +def main_threads(POST_ID=None) -> None: + """Pipeline chính: Lấy nội dung từ Threads → Tạo video.""" + global thread_id, thread_object + + from threads.threads_client import get_threads_posts + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.threads_screenshot import get_screenshots_of_threads_posts + from video_creation.voices import save_text_to_mp3 + + thread_object = get_threads_posts(POST_ID) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + + length, number_of_comments = save_text_to_mp3(thread_object) length = math.ceil(length) - get_screenshots_of_reddit_posts(reddit_object, number_of_comments) + + get_screenshots_of_threads_posts(thread_object, number_of_comments) + bg_config = { "video": get_background_config("video"), "audio": get_background_config("audio"), } download_background_video(bg_config["video"]) download_background_audio(bg_config["audio"]) - chop_background(bg_config, length, reddit_object) - make_final_video(number_of_comments, length, reddit_object, bg_config) + chop_background(bg_config, length, thread_object) + make_final_video(number_of_comments, length, thread_object, bg_config) + +def main_threads_with_upload(POST_ID=None) -> None: + """Pipeline đầy đủ: Threads → Video → Upload lên các platform.""" + from scheduler.pipeline import run_pipeline + run_pipeline(POST_ID) -def run_many(times) -> None: + +def main_reddit(POST_ID=None) -> None: + """Legacy mode: Sử dụng Reddit làm nguồn nội dung.""" + from reddit.subreddit import get_subreddit_threads + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts + from video_creation.voices import save_text_to_mp3 + + global thread_id, thread_object + thread_object = get_subreddit_threads(POST_ID) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + length, number_of_comments = save_text_to_mp3(thread_object) + length = math.ceil(length) + get_screenshots_of_reddit_posts(thread_object, number_of_comments) + bg_config = { + "video": get_background_config("video"), + "audio": get_background_config("audio"), + } + download_background_video(bg_config["video"]) + download_background_audio(bg_config["audio"]) + chop_background(bg_config, length, thread_object) + make_final_video(number_of_comments, length, thread_object, bg_config) + + +def run_many(times, use_reddit=False) -> None: + """Chạy nhiều lần tạo video.""" + main_func = main_reddit if use_reddit else main_threads for x in range(1, times + 1): - print_step( - f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}' - ) - main() + print_step(f"Đang tạo video {x}/{times}...") + main_func() Popen("cls" if name == "nt" else "clear", shell=True).wait() def shutdown() -> NoReturn: - if "reddit_id" in globals(): - print_markdown("## Clearing temp files") - cleanup(reddit_id) + if "thread_id" in globals(): + print_markdown("## Đang dọn dẹp file tạm...") + cleanup(thread_id) - print("Exiting...") + print("Thoát...") sys.exit() +def parse_args(): + """Parse command line arguments.""" + import argparse + parser = argparse.ArgumentParser(description="Threads Video Maker Bot - Vietnam Edition") + parser.add_argument( + "--mode", + choices=["manual", "auto", "scheduled"], + default="manual", + help="Chế độ chạy: manual (mặc định), auto (tạo + upload), scheduled (lên lịch)", + ) + parser.add_argument( + "--reddit", + action="store_true", + help="Sử dụng Reddit thay vì Threads (legacy mode)", + ) + return parser.parse_args() + + if __name__ == "__main__": if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]: print( - "Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again." + "Ứng dụng yêu cầu Python 3.10, 3.11 hoặc 3.12. Vui lòng cài đặt phiên bản phù hợp." ) sys.exit() + + args = parse_args() ffmpeg_install() directory = Path().absolute() config = settings.check_toml( @@ -95,42 +183,102 @@ if __name__ == "__main__": ) config is False and sys.exit() + # Kiểm tra TikTok TTS session if ( - not settings.config["settings"]["tts"]["tiktok_sessionid"] - or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" + not settings.config["settings"]["tts"].get("tiktok_sessionid", "") ) and config["settings"]["tts"]["voice_choice"] == "tiktok": print_substep( - "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", + "TikTok TTS cần sessionid! Xem tài liệu để biết cách lấy.", "bold red", ) sys.exit() + try: - if config["reddit"]["thread"]["post_id"]: - for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): - index += 1 - print_step( - f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}' - ) - main(post_id) - Popen("cls" if name == "nt" else "clear", shell=True).wait() - elif config["settings"]["times_to_run"]: - run_many(config["settings"]["times_to_run"]) + if args.mode == "scheduled": + # Chế độ lên lịch tự động + print_step("🕐 Khởi động chế độ lên lịch tự động...") + from scheduler.pipeline import run_scheduled + run_scheduled() + + elif args.mode == "auto": + # Chế độ tự động: tạo + upload + print_step("🚀 Khởi động chế độ tự động (tạo + upload)...") + if args.reddit: + # Legacy Reddit mode + main_reddit() + else: + thread_config = config.get("threads", {}).get("thread", {}) + post_id = thread_config.get("post_id", "") + if post_id: + for index, pid in enumerate(post_id.split("+")): + index += 1 + print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...") + main_threads_with_upload(pid) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + for i in range(config["settings"]["times_to_run"]): + print_step(f"Đang tạo video {i + 1}/{config['settings']['times_to_run']}...") + main_threads_with_upload() + else: + main_threads_with_upload() + else: - main() + # Chế độ manual (mặc định) + if args.reddit: + # Legacy Reddit mode + from prawcore import ResponseException + try: + if config["reddit"]["thread"]["post_id"]: + for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): + index += 1 + print_step(f"Đang xử lý post {index}...") + main_reddit(post_id) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + run_many(config["settings"]["times_to_run"], use_reddit=True) + else: + main_reddit() + except ResponseException: + print_markdown("## Thông tin đăng nhập Reddit không hợp lệ") + print_markdown("Vui lòng kiểm tra config.toml") + shutdown() + else: + # Threads mode (mặc định) + thread_config = config.get("threads", {}).get("thread", {}) + post_id = thread_config.get("post_id", "") + if post_id: + for index, pid in enumerate(post_id.split("+")): + index += 1 + print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...") + main_threads(pid) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + run_many(config["settings"]["times_to_run"]) + else: + main_threads() + except KeyboardInterrupt: shutdown() - except ResponseException: - print_markdown("## Invalid credentials") - print_markdown("Please check your credentials in the config.toml file") - shutdown() except Exception as err: - config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" - config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" - config["settings"]["tts"]["openai_api_key"] = "REDACTED" + # Redact sensitive values + try: + config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" + config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" + config["settings"]["tts"]["openai_api_key"] = "REDACTED" + if "threads" in config and "creds" in config["threads"]: + config["threads"]["creds"]["access_token"] = "REDACTED" + if "uploaders" in config: + for platform in config["uploaders"]: + for key in ["access_token", "client_secret", "refresh_token"]: + if key in config["uploaders"][platform]: + config["uploaders"][platform][key] = "REDACTED" + except (KeyError, TypeError): + pass + print_step( - f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n" - f"Version: {__VERSION__} \n" - f"Error: {err} \n" - f'Config: {config["settings"]}' + f"Đã xảy ra lỗi! Vui lòng thử lại hoặc báo lỗi trên GitHub.\n" + f"Phiên bản: {__VERSION__}\n" + f"Lỗi: {err}\n" + f'Config: {config.get("settings", {})}' ) raise err diff --git a/requirements.txt b/requirements.txt index 7aa38ee..fbd90c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +# Core dependencies boto3==1.36.8 botocore==1.36.8 gTTS==2.5.4 @@ -19,3 +20,8 @@ transformers==4.52.4 ffmpeg-python==0.2.0 elevenlabs==1.57.0 yt-dlp==2025.10.22 + +# New dependencies for Threads Vietnam Video Maker +google-api-python-client==2.166.0 +google-auth-oauthlib==1.2.1 +APScheduler==3.11.0 diff --git a/scheduler/__init__.py b/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scheduler/pipeline.py b/scheduler/pipeline.py new file mode 100644 index 0000000..8c8d1b0 --- /dev/null +++ b/scheduler/pipeline.py @@ -0,0 +1,223 @@ +""" +Scheduler - Hệ thống lên lịch tự động tạo và đăng video. + +Sử dụng APScheduler để lên lịch các tác vụ tự động. +""" + +import math +import os +import sys +from datetime import datetime +from os import name +from pathlib import Path +from subprocess import Popen +from typing import Optional + +from utils import settings +from utils.cleanup import cleanup +from utils.console import print_markdown, print_step, print_substep +from utils.id import extract_id + + +def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: + """Chạy toàn bộ pipeline tạo video từ Threads. + + Pipeline: + 1. Lấy nội dung từ Threads + 2. Tạo TTS audio + 3. Tạo screenshots + 4. Tải background video/audio + 5. Ghép video cuối cùng + 6. Upload lên các platform (nếu được cấu hình) + + Args: + post_id: ID cụ thể của thread (optional). + + Returns: + Đường dẫn file video đã tạo, hoặc None nếu thất bại. + """ + from threads.threads_client import get_threads_posts + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.threads_screenshot import get_screenshots_of_threads_posts + from video_creation.voices import save_text_to_mp3 + + print_step("🚀 Bắt đầu pipeline tạo video...") + + try: + # Step 1: Lấy nội dung từ Threads + print_step("📱 Bước 1: Lấy nội dung từ Threads...") + thread_object = get_threads_posts(post_id) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + + # Step 2: Tạo TTS audio + print_step("🎙️ Bước 2: Tạo audio TTS...") + length, number_of_comments = save_text_to_mp3(thread_object) + length = math.ceil(length) + + # Step 3: Tạo screenshots + print_step("📸 Bước 3: Tạo hình ảnh...") + get_screenshots_of_threads_posts(thread_object, number_of_comments) + + # Step 4: Background + print_step("🎬 Bước 4: Xử lý background...") + bg_config = { + "video": get_background_config("video"), + "audio": get_background_config("audio"), + } + download_background_video(bg_config["video"]) + download_background_audio(bg_config["audio"]) + chop_background(bg_config, length, thread_object) + + # Step 5: Ghép video cuối cùng + print_step("🎥 Bước 5: Tạo video cuối cùng...") + make_final_video(number_of_comments, length, thread_object, bg_config) + + # Tìm file video đã tạo + subreddit = settings.config.get("threads", {}).get("thread", {}).get("channel_name", "threads") + results_dir = f"./results/{subreddit}" + video_path = None + if os.path.exists(results_dir): + files = sorted( + [f for f in os.listdir(results_dir) if f.endswith(".mp4")], + key=lambda x: os.path.getmtime(os.path.join(results_dir, x)), + reverse=True, + ) + if files: + video_path = os.path.join(results_dir, files[0]) + + # Step 6: Upload (nếu cấu hình) + upload_config = settings.config.get("uploaders", {}) + has_uploaders = any( + upload_config.get(p, {}).get("enabled", False) + for p in ["youtube", "tiktok", "facebook"] + ) + + if has_uploaders and video_path: + print_step("📤 Bước 6: Upload video lên các platform...") + from uploaders.upload_manager import UploadManager + + manager = UploadManager() + title = thread_object.get("thread_title", "Threads Video")[:100] + description = thread_object.get("thread_post", "")[:500] + + # Tìm thumbnail nếu có + thumbnail_path = None + thumb_candidate = f"./assets/temp/{thread_id}/thumbnail.png" + if os.path.exists(thumb_candidate): + thumbnail_path = thumb_candidate + + results = manager.upload_to_all( + video_path=video_path, + title=title, + description=description, + thumbnail_path=thumbnail_path, + ) + + print_step("📊 Kết quả upload:") + for platform, url in results.items(): + if url: + print_substep(f" ✅ {platform}: {url}", style="bold green") + else: + print_substep(f" ❌ {platform}: Thất bại", style="bold red") + + print_step("✅ Pipeline hoàn tất!") + return video_path + + except Exception as e: + print_substep(f"❌ Lỗi pipeline: {e}", style="bold red") + raise + + +def run_scheduled(): + """Chạy pipeline theo lịch trình đã cấu hình. + + Sử dụng APScheduler để lên lịch. + """ + try: + from apscheduler.schedulers.blocking import BlockingScheduler + from apscheduler.triggers.cron import CronTrigger + except ImportError: + print_substep( + "Cần cài đặt APScheduler: pip install apscheduler", + style="bold red", + ) + return + + scheduler_config = settings.config.get("scheduler", {}) + enabled = scheduler_config.get("enabled", False) + + if not enabled: + print_substep("Scheduler chưa được kích hoạt trong config!", style="bold yellow") + return + + timezone = scheduler_config.get("timezone", "Asia/Ho_Chi_Minh") + cron_expression = scheduler_config.get("cron", "0 */6 * * *") # Mặc định mỗi 6 giờ + max_videos_per_day = scheduler_config.get("max_videos_per_day", 4) + + # Parse cron expression + cron_parts = cron_expression.split() + if len(cron_parts) != 5: + print_substep("Cron expression không hợp lệ! Format: minute hour day month weekday", style="bold red") + return + + scheduler = BlockingScheduler(timezone=timezone) + + videos_today = {"count": 0, "date": datetime.now().strftime("%Y-%m-%d")} + + def scheduled_job(): + """Job được chạy theo lịch.""" + current_date = datetime.now().strftime("%Y-%m-%d") + + # Reset counter nếu sang ngày mới + if current_date != videos_today["date"]: + videos_today["count"] = 0 + videos_today["date"] = current_date + + if videos_today["count"] >= max_videos_per_day: + print_substep( + f"Đã đạt giới hạn {max_videos_per_day} video/ngày. Bỏ qua.", + style="bold yellow", + ) + return + + print_step(f"⏰ Scheduler: Bắt đầu tạo video lúc {datetime.now().strftime('%H:%M:%S')}...") + try: + result = run_pipeline() + if result: + videos_today["count"] += 1 + print_substep( + f"Video #{videos_today['count']}/{max_videos_per_day} ngày hôm nay", + style="bold blue", + ) + except Exception as e: + print_substep(f"Scheduler job thất bại: {e}", style="bold red") + + trigger = CronTrigger( + minute=cron_parts[0], + hour=cron_parts[1], + day=cron_parts[2], + month=cron_parts[3], + day_of_week=cron_parts[4], + timezone=timezone, + ) + + scheduler.add_job(scheduled_job, trigger, id="video_pipeline", replace_existing=True) + + print_step(f"📅 Scheduler đã khởi động!") + print_substep(f" Cron: {cron_expression}", style="bold blue") + print_substep(f" Timezone: {timezone}", style="bold blue") + print_substep(f" Max videos/ngày: {max_videos_per_day}", style="bold blue") + print_substep(" Nhấn Ctrl+C để dừng", style="bold yellow") + + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + scheduler.shutdown() + print_step("Scheduler đã dừng.") diff --git a/threads/__init__.py b/threads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/threads/threads_client.py b/threads/threads_client.py new file mode 100644 index 0000000..74ea2c8 --- /dev/null +++ b/threads/threads_client.py @@ -0,0 +1,245 @@ +""" +Threads API Client - Lấy nội dung từ Meta Threads cho thị trường Việt Nam. + +Meta Threads API sử dụng Graph API endpoint. +Docs: https://developers.facebook.com/docs/threads +""" + +import re +from typing import Dict, List, Optional + +import requests + +from utils import settings +from utils.console import print_step, print_substep +from utils.videos import check_done +from utils.voice import sanitize_text + + +THREADS_API_BASE = "https://graph.threads.net/v1.0" + + +class ThreadsClient: + """Client để tương tác với Threads API (Meta).""" + + def __init__(self): + self.access_token = settings.config["threads"]["creds"]["access_token"] + self.user_id = settings.config["threads"]["creds"]["user_id"] + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.access_token}", + }) + + def _get(self, endpoint: str, params: Optional[dict] = None) -> dict: + """Make a GET request to the Threads API.""" + url = f"{THREADS_API_BASE}/{endpoint}" + if params is None: + params = {} + params["access_token"] = self.access_token + response = self.session.get(url, params=params) + response.raise_for_status() + return response.json() + + def get_user_threads(self, user_id: Optional[str] = None, limit: int = 25) -> List[dict]: + """Lấy danh sách threads của user. + + Args: + user_id: Threads user ID. Mặc định là user đã cấu hình. + limit: Số lượng threads tối đa cần lấy. + + Returns: + Danh sách các thread objects. + """ + uid = user_id or self.user_id + data = self._get(f"{uid}/threads", params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode,is_reply,reply_audience", + "limit": limit, + }) + return data.get("data", []) + + def get_thread_replies(self, thread_id: str, limit: int = 50) -> List[dict]: + """Lấy replies (comments) của một thread. + + Args: + thread_id: ID của thread. + limit: Số lượng replies tối đa. + + Returns: + Danh sách replies. + """ + data = self._get(f"{thread_id}/replies", params={ + "fields": "id,text,timestamp,username,permalink,hide_status", + "limit": limit, + "reverse": "true", + }) + return data.get("data", []) + + def get_thread_by_id(self, thread_id: str) -> dict: + """Lấy thông tin chi tiết của một thread. + + Args: + thread_id: ID của thread. + + Returns: + Thread object. + """ + return self._get(thread_id, params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode", + }) + + def search_threads_by_keyword(self, threads: List[dict], keywords: List[str]) -> List[dict]: + """Lọc threads theo từ khóa. + + Args: + threads: Danh sách threads. + keywords: Danh sách từ khóa cần tìm. + + Returns: + Danh sách threads chứa từ khóa. + """ + filtered = [] + for thread in threads: + text = thread.get("text", "").lower() + for keyword in keywords: + if keyword.lower() in text: + filtered.append(thread) + break + return filtered + + +def _contains_blocked_words(text: str) -> bool: + """Kiểm tra xem text có chứa từ bị chặn không.""" + blocked_words = settings.config["threads"]["thread"].get("blocked_words", "") + if not blocked_words: + return False + blocked_list = [w.strip().lower() for w in blocked_words.split(",") if w.strip()] + text_lower = text.lower() + return any(word in text_lower for word in blocked_list) + + +def get_threads_posts(POST_ID: str = None) -> dict: + """Lấy nội dung từ Threads để tạo video. + + Tương tự get_subreddit_threads() nhưng cho Threads. + + Args: + POST_ID: ID cụ thể của thread. Nếu None, lấy thread mới nhất phù hợp. + + Returns: + Dict chứa thread content và replies. + """ + print_substep("Đang kết nối với Threads API...") + + client = ThreadsClient() + content = {} + + thread_config = settings.config["threads"]["thread"] + max_comment_length = int(thread_config.get("max_comment_length", 500)) + min_comment_length = int(thread_config.get("min_comment_length", 1)) + min_comments = int(thread_config.get("min_comments", 5)) + + print_step("Đang lấy nội dung từ Threads...") + + if POST_ID: + # Lấy thread cụ thể theo ID + thread = client.get_thread_by_id(POST_ID) + else: + # Lấy threads mới nhất và chọn thread phù hợp + target_user = thread_config.get("target_user_id", "") or client.user_id + threads_list = client.get_user_threads(user_id=target_user, limit=25) + + if not threads_list: + print_substep("Không tìm thấy threads nào!", style="bold red") + raise ValueError("No threads found") + + # Lọc theo từ khóa nếu có + keywords = thread_config.get("keywords", "") + if keywords: + keyword_list = [k.strip() for k in keywords.split(",") if k.strip()] + threads_list = client.search_threads_by_keyword(threads_list, keyword_list) + + # Chọn thread phù hợp (chưa tạo video, đủ replies) + thread = None + for t in threads_list: + thread_id = t.get("id", "") + # Kiểm tra xem đã tạo video cho thread này chưa + text = t.get("text", "") + if not text or _contains_blocked_words(text): + continue + # Kiểm tra số lượng replies + try: + replies = client.get_thread_replies(thread_id, limit=min_comments + 5) + if len(replies) >= min_comments: + thread = t + break + except Exception: + continue + + if thread is None: + # Nếu không tìm được thread đủ comments, lấy thread đầu tiên + if threads_list: + thread = threads_list[0] + else: + print_substep("Không tìm thấy thread phù hợp!", style="bold red") + raise ValueError("No suitable thread found") + + thread_id = thread.get("id", "") + thread_text = thread.get("text", "") + thread_url = thread.get("permalink", f"https://www.threads.net/post/{thread.get('shortcode', '')}") + thread_username = thread.get("username", "unknown") + + print_substep(f"Video sẽ được tạo từ: {thread_text[:100]}...", style="bold green") + print_substep(f"Thread URL: {thread_url}", style="bold green") + print_substep(f"Tác giả: @{thread_username}", style="bold blue") + + content["thread_url"] = thread_url + content["thread_title"] = thread_text[:200] if len(thread_text) > 200 else thread_text + content["thread_id"] = re.sub(r"[^\w\s-]", "", thread_id) + content["thread_author"] = f"@{thread_username}" + content["is_nsfw"] = False + content["thread_post"] = thread_text + content["comments"] = [] + + if settings.config["settings"].get("storymode", False): + # Story mode - đọc toàn bộ nội dung bài viết + content["thread_post"] = thread_text + else: + # Comment mode - lấy replies + try: + replies = client.get_thread_replies(thread_id, limit=50) + except Exception as e: + print_substep(f"Lỗi khi lấy replies: {e}", style="bold red") + replies = [] + + for reply in replies: + reply_text = reply.get("text", "") + reply_username = reply.get("username", "unknown") + + if not reply_text: + continue + if reply.get("hide_status", "") == "HIDDEN": + continue + if _contains_blocked_words(reply_text): + continue + + sanitised = sanitize_text(reply_text) + if not sanitised or sanitised.strip() == "": + continue + + if len(reply_text) > max_comment_length: + continue + if len(reply_text) < min_comment_length: + continue + + content["comments"].append({ + "comment_body": reply_text, + "comment_url": reply.get("permalink", ""), + "comment_id": re.sub(r"[^\w\s-]", "", reply.get("id", "")), + "comment_author": f"@{reply_username}", + }) + + print_substep( + f"Đã lấy nội dung từ Threads thành công! ({len(content.get('comments', []))} replies)", + style="bold green", + ) + return content diff --git a/uploaders/__init__.py b/uploaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uploaders/base_uploader.py b/uploaders/base_uploader.py new file mode 100644 index 0000000..0405096 --- /dev/null +++ b/uploaders/base_uploader.py @@ -0,0 +1,117 @@ +""" +Base Uploader - Lớp cơ sở cho tất cả uploaders. +""" + +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional, List + +from utils.console import print_step, print_substep + + +@dataclass +class VideoMetadata: + """Metadata cho video cần upload.""" + file_path: str + title: str + description: str = "" + tags: List[str] = field(default_factory=list) + hashtags: List[str] = field(default_factory=list) + thumbnail_path: Optional[str] = None + schedule_time: Optional[str] = None # ISO 8601 format + privacy: str = "public" # public, private, unlisted + category: str = "Entertainment" + language: str = "vi" # Vietnamese + + +class BaseUploader(ABC): + """Lớp cơ sở cho tất cả platform uploaders.""" + + platform_name: str = "Unknown" + + def __init__(self): + self._authenticated = False + + @abstractmethod + def authenticate(self) -> bool: + """Xác thực với platform API. + + Returns: + True nếu xác thực thành công. + """ + pass + + @abstractmethod + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên platform. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL của video đã upload, hoặc None nếu thất bại. + """ + pass + + def validate_video(self, metadata: VideoMetadata) -> bool: + """Kiểm tra video có hợp lệ trước khi upload. + + Args: + metadata: VideoMetadata cần kiểm tra. + + Returns: + True nếu hợp lệ. + """ + if not os.path.exists(metadata.file_path): + print_substep(f"[{self.platform_name}] File không tồn tại: {metadata.file_path}", style="bold red") + return False + + file_size = os.path.getsize(metadata.file_path) + if file_size == 0: + print_substep(f"[{self.platform_name}] File rỗng: {metadata.file_path}", style="bold red") + return False + + if not metadata.title: + print_substep(f"[{self.platform_name}] Thiếu tiêu đề video", style="bold red") + return False + + return True + + def safe_upload(self, metadata: VideoMetadata, max_retries: int = 3) -> Optional[str]: + """Upload video với retry logic. + + Args: + metadata: VideoMetadata chứa thông tin video. + max_retries: Số lần thử lại tối đa. + + Returns: + URL của video đã upload, hoặc None nếu thất bại. + """ + if not self.validate_video(metadata): + return None + + if not self._authenticated: + print_step(f"Đang xác thực với {self.platform_name}...") + if not self.authenticate(): + print_substep(f"Xác thực {self.platform_name} thất bại!", style="bold red") + return None + + for attempt in range(1, max_retries + 1): + try: + print_step(f"Đang upload lên {self.platform_name} (lần {attempt}/{max_retries})...") + url = self.upload(metadata) + if url: + print_substep( + f"Upload {self.platform_name} thành công! URL: {url}", + style="bold green", + ) + return url + except Exception as e: + print_substep( + f"[{self.platform_name}] Lỗi upload (lần {attempt}): {e}", + style="bold red", + ) + + print_substep(f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red") + return None diff --git a/uploaders/facebook_uploader.py b/uploaders/facebook_uploader.py new file mode 100644 index 0000000..527b7d1 --- /dev/null +++ b/uploaders/facebook_uploader.py @@ -0,0 +1,215 @@ +""" +Facebook Uploader - Upload video lên Facebook sử dụng Graph API. + +Yêu cầu: +- Facebook Developer App +- Page Access Token (cho Page upload) hoặc User Access Token +- Permissions: publish_video, pages_manage_posts +Docs: https://developers.facebook.com/docs/video-api/guides/publishing +""" + +import json +import os +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class FacebookUploader(BaseUploader): + """Upload video lên Facebook Page/Profile.""" + + platform_name = "Facebook" + + # Facebook API endpoints + GRAPH_API_BASE = "https://graph.facebook.com/v21.0" + + # Limits + MAX_DESCRIPTION_LENGTH = 63206 + MAX_TITLE_LENGTH = 255 + MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("facebook", {}) + self.access_token = None + self.page_id = None + + def authenticate(self) -> bool: + """Xác thực với Facebook Graph API. + + Sử dụng Page Access Token cho upload lên Page. + + Returns: + True nếu xác thực thành công. + """ + self.access_token = self.config.get("access_token", "") + self.page_id = self.config.get("page_id", "") + + if not self.access_token: + print_substep("Facebook: Thiếu access_token", style="bold red") + return False + + if not self.page_id: + print_substep("Facebook: Thiếu page_id", style="bold red") + return False + + # Verify token + try: + response = requests.get( + f"{self.GRAPH_API_BASE}/me", + params={"access_token": self.access_token}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + + if "id" in data: + self._authenticated = True + print_substep(f"Facebook: Xác thực thành công (Page: {data.get('name', self.page_id)}) ✅", style="bold green") + return True + else: + print_substep("Facebook: Token không hợp lệ", style="bold red") + return False + + except Exception as e: + print_substep(f"Facebook: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên Facebook Page. + + Sử dụng Resumable Upload API cho file lớn. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên Facebook, hoặc None nếu thất bại. + """ + if not self.access_token or not self.page_id: + return None + + file_size = os.path.getsize(metadata.file_path) + + title = metadata.title[:self.MAX_TITLE_LENGTH] + description = self._build_description(metadata) + + # Step 1: Initialize upload session + try: + init_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data={ + "upload_phase": "start", + "file_size": file_size, + "access_token": self.access_token, + }, + timeout=30, + ) + init_response.raise_for_status() + init_data = init_response.json() + + upload_session_id = init_data.get("upload_session_id", "") + video_id = init_data.get("video_id", "") + + if not upload_session_id: + print_substep("Facebook: Không thể khởi tạo upload session", style="bold red") + return None + + except Exception as e: + print_substep(f"Facebook: Lỗi khởi tạo upload - {e}", style="bold red") + return None + + # Step 2: Upload video chunks + try: + chunk_size = 4 * 1024 * 1024 # 4 MB chunks + start_offset = 0 + + with open(metadata.file_path, "rb") as video_file: + while start_offset < file_size: + chunk = video_file.read(chunk_size) + transfer_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data={ + "upload_phase": "transfer", + "upload_session_id": upload_session_id, + "start_offset": start_offset, + "access_token": self.access_token, + }, + files={"video_file_chunk": ("chunk", chunk, "application/octet-stream")}, + timeout=120, + ) + transfer_response.raise_for_status() + transfer_data = transfer_response.json() + + start_offset = int(transfer_data.get("start_offset", file_size)) + end_offset = int(transfer_data.get("end_offset", file_size)) + + if start_offset >= file_size: + break + + except Exception as e: + print_substep(f"Facebook: Lỗi upload file - {e}", style="bold red") + return None + + # Step 3: Finish upload + try: + finish_data = { + "upload_phase": "finish", + "upload_session_id": upload_session_id, + "access_token": self.access_token, + "title": title, + "description": description[:self.MAX_DESCRIPTION_LENGTH], + } + + if metadata.schedule_time: + finish_data["scheduled_publish_time"] = metadata.schedule_time + finish_data["published"] = "false" + + if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path): + with open(metadata.thumbnail_path, "rb") as thumb: + finish_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data=finish_data, + files={"thumb": thumb}, + timeout=60, + ) + else: + finish_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data=finish_data, + timeout=60, + ) + + finish_response.raise_for_status() + finish_result = finish_response.json() + + if finish_result.get("success", False): + video_url = f"https://www.facebook.com/{self.page_id}/videos/{video_id}" + return video_url + else: + print_substep("Facebook: Upload hoàn tất nhưng không thành công", style="bold red") + return None + + except Exception as e: + print_substep(f"Facebook: Lỗi kết thúc upload - {e}", style="bold red") + return None + + def _build_description(self, metadata: VideoMetadata) -> str: + """Tạo description cho video Facebook.""" + parts = [] + if metadata.description: + parts.append(metadata.description) + + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + parts.append(hashtag_str) + + parts.append("") + parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot") + + return "\n".join(parts) diff --git a/uploaders/tiktok_uploader.py b/uploaders/tiktok_uploader.py new file mode 100644 index 0000000..561c44c --- /dev/null +++ b/uploaders/tiktok_uploader.py @@ -0,0 +1,223 @@ +""" +TikTok Uploader - Upload video lên TikTok sử dụng Content Posting API. + +Yêu cầu: +- TikTok Developer App +- Content Posting API access +- OAuth2 access token +Docs: https://developers.tiktok.com/doc/content-posting-api-get-started +""" + +import json +import os +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class TikTokUploader(BaseUploader): + """Upload video lên TikTok.""" + + platform_name = "TikTok" + + # TikTok API endpoints + API_BASE = "https://open.tiktokapis.com/v2" + TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/" + + # Limits + MAX_CAPTION_LENGTH = 2200 + MAX_FILE_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB + MIN_DURATION = 3 # seconds + MAX_DURATION = 600 # 10 minutes + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("tiktok", {}) + self.access_token = None + + def authenticate(self) -> bool: + """Xác thực với TikTok API sử dụng refresh token. + + Returns: + True nếu xác thực thành công. + """ + client_key = self.config.get("client_key", "") + client_secret = self.config.get("client_secret", "") + refresh_token = self.config.get("refresh_token", "") + + if not all([client_key, client_secret, refresh_token]): + print_substep( + "TikTok: Thiếu credentials (client_key, client_secret, refresh_token)", + style="bold red", + ) + return False + + try: + response = requests.post(self.TOKEN_URL, json={ + "client_key": client_key, + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, timeout=30) + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data.get("data", {}).get("access_token", "") + + if self.access_token: + self._authenticated = True + print_substep("TikTok: Xác thực thành công! ✅", style="bold green") + return True + else: + print_substep("TikTok: Không lấy được access token", style="bold red") + return False + + except Exception as e: + print_substep(f"TikTok: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên TikTok sử dụng Content Posting API. + + Flow: + 1. Initialize upload → get upload_url + 2. Upload video file to upload_url + 3. Publish video + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên TikTok, hoặc None nếu thất bại. + """ + if not self.access_token: + return None + + file_size = os.path.getsize(metadata.file_path) + if file_size > self.MAX_FILE_SIZE: + print_substep(f"TikTok: File quá lớn ({file_size} bytes)", style="bold red") + return None + + # Build caption + caption = self._build_caption(metadata) + + # Step 1: Initialize upload + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json; charset=UTF-8", + } + + init_body = { + "post_info": { + "title": caption, + "privacy_level": self._map_privacy(metadata.privacy), + "disable_duet": False, + "disable_comment": False, + "disable_stitch": False, + }, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": file_size, + "chunk_size": file_size, # Single chunk upload + "total_chunk_count": 1, + }, + } + + if metadata.schedule_time: + init_body["post_info"]["schedule_time"] = metadata.schedule_time + + try: + init_response = requests.post( + f"{self.API_BASE}/post/publish/inbox/video/init/", + headers=headers, + json=init_body, + timeout=30, + ) + init_response.raise_for_status() + init_data = init_response.json() + + publish_id = init_data.get("data", {}).get("publish_id", "") + upload_url = init_data.get("data", {}).get("upload_url", "") + + if not upload_url: + print_substep("TikTok: Không lấy được upload URL", style="bold red") + return None + + except Exception as e: + print_substep(f"TikTok: Lỗi khởi tạo upload - {e}", style="bold red") + return None + + # Step 2: Upload video file + try: + with open(metadata.file_path, "rb") as video_file: + upload_headers = { + "Content-Type": "video/mp4", + "Content-Length": str(file_size), + "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", + } + upload_response = requests.put( + upload_url, + headers=upload_headers, + data=video_file, + timeout=600, + ) + upload_response.raise_for_status() + + except Exception as e: + print_substep(f"TikTok: Lỗi upload file - {e}", style="bold red") + return None + + # Step 3: Check publish status + status_url = f"{self.API_BASE}/post/publish/status/fetch/" + for attempt in range(10): + try: + status_response = requests.post( + status_url, + headers=headers, + json={"publish_id": publish_id}, + timeout=30, + ) + status_data = status_response.json() + status = status_data.get("data", {}).get("status", "") + + if status == "PUBLISH_COMPLETE": + print_substep("TikTok: Upload thành công! ✅", style="bold green") + return f"https://www.tiktok.com/@user/video/{publish_id}" + elif status == "FAILED": + reason = status_data.get("data", {}).get("fail_reason", "Unknown") + print_substep(f"TikTok: Upload thất bại - {reason}", style="bold red") + return None + + time.sleep(5) # Wait 5 seconds before checking again + except Exception: + time.sleep(5) + + print_substep("TikTok: Upload timeout", style="bold yellow") + return None + + def _build_caption(self, metadata: VideoMetadata) -> str: + """Tạo caption cho video TikTok.""" + parts = [] + if metadata.title: + parts.append(metadata.title) + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + parts.append(hashtag_str) + caption = " ".join(parts) + return caption[:self.MAX_CAPTION_LENGTH] + + @staticmethod + def _map_privacy(privacy: str) -> str: + """Map privacy setting to TikTok format.""" + mapping = { + "public": "PUBLIC_TO_EVERYONE", + "private": "SELF_ONLY", + "friends": "MUTUAL_FOLLOW_FRIENDS", + "unlisted": "SELF_ONLY", + } + return mapping.get(privacy, "PUBLIC_TO_EVERYONE") diff --git a/uploaders/upload_manager.py b/uploaders/upload_manager.py new file mode 100644 index 0000000..e6c46e4 --- /dev/null +++ b/uploaders/upload_manager.py @@ -0,0 +1,137 @@ +""" +Upload Manager - Quản lý upload video lên nhiều platform cùng lúc. +""" + +import os +from typing import Dict, List, Optional + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from uploaders.youtube_uploader import YouTubeUploader +from uploaders.tiktok_uploader import TikTokUploader +from uploaders.facebook_uploader import FacebookUploader +from utils import settings +from utils.console import print_step, print_substep + + +class UploadManager: + """Quản lý upload video lên nhiều platform.""" + + PLATFORM_MAP = { + "youtube": YouTubeUploader, + "tiktok": TikTokUploader, + "facebook": FacebookUploader, + } + + def __init__(self): + self.uploaders: Dict[str, BaseUploader] = {} + self._init_uploaders() + + def _init_uploaders(self): + """Khởi tạo uploaders dựa trên cấu hình.""" + upload_config = settings.config.get("uploaders", {}) + + for platform_name, uploader_class in self.PLATFORM_MAP.items(): + platform_config = upload_config.get(platform_name, {}) + if platform_config.get("enabled", False): + self.uploaders[platform_name] = uploader_class() + print_substep(f"Đã kích hoạt uploader: {platform_name}", style="bold blue") + + def upload_to_all( + self, + video_path: str, + title: str, + description: str = "", + tags: Optional[List[str]] = None, + hashtags: Optional[List[str]] = None, + thumbnail_path: Optional[str] = None, + schedule_time: Optional[str] = None, + privacy: str = "public", + ) -> Dict[str, Optional[str]]: + """Upload video lên tất cả platform đã cấu hình. + + Args: + video_path: Đường dẫn file video. + title: Tiêu đề video. + description: Mô tả video. + tags: Danh sách tags. + hashtags: Danh sách hashtags. + thumbnail_path: Đường dẫn thumbnail. + schedule_time: Thời gian lên lịch (ISO 8601). + privacy: Quyền riêng tư (public/private/unlisted). + + Returns: + Dict mapping platform name -> video URL (hoặc None nếu thất bại). + """ + if not self.uploaders: + print_substep("Không có uploader nào được kích hoạt!", style="bold yellow") + return {} + + metadata = VideoMetadata( + file_path=video_path, + title=title, + description=description, + tags=tags or [], + hashtags=hashtags or self._default_hashtags(), + thumbnail_path=thumbnail_path, + schedule_time=schedule_time, + privacy=privacy, + language="vi", + ) + + results = {} + print_step(f"Đang upload video lên {len(self.uploaders)} platform...") + + for platform_name, uploader in self.uploaders.items(): + print_step(f"📤 Đang upload lên {platform_name}...") + url = uploader.safe_upload(metadata) + results[platform_name] = url + + # Summary + print_step("📊 Kết quả upload:") + success_count = 0 + for platform, url in results.items(): + if url: + print_substep(f" ✅ {platform}: {url}", style="bold green") + success_count += 1 + else: + print_substep(f" ❌ {platform}: Thất bại", style="bold red") + + print_substep( + f"Upload hoàn tất: {success_count}/{len(results)} platform thành công", + style="bold blue", + ) + + return results + + def upload_to_platform( + self, + platform_name: str, + metadata: VideoMetadata, + ) -> Optional[str]: + """Upload video lên một platform cụ thể. + + Args: + platform_name: Tên platform. + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video, hoặc None nếu thất bại. + """ + if platform_name not in self.uploaders: + print_substep(f"Platform '{platform_name}' chưa được kích hoạt!", style="bold red") + return None + + return self.uploaders[platform_name].safe_upload(metadata) + + @staticmethod + def _default_hashtags() -> List[str]: + """Hashtags mặc định cho thị trường Việt Nam.""" + return [ + "threads", + "viral", + "vietnam", + "trending", + "foryou", + "fyp", + "threadsvn", + ] diff --git a/uploaders/youtube_uploader.py b/uploaders/youtube_uploader.py new file mode 100644 index 0000000..332e837 --- /dev/null +++ b/uploaders/youtube_uploader.py @@ -0,0 +1,219 @@ +""" +YouTube Uploader - Upload video lên YouTube sử dụng YouTube Data API v3. + +Yêu cầu: +- Google API credentials (OAuth2) +- YouTube Data API v3 enabled +- Scopes: https://www.googleapis.com/auth/youtube.upload +""" + +import os +import json +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class YouTubeUploader(BaseUploader): + """Upload video lên YouTube.""" + + platform_name = "YouTube" + + # YouTube API endpoints + UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos" + VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos" + TOKEN_URL = "https://oauth2.googleapis.com/token" + + # Limits + MAX_TITLE_LENGTH = 100 + MAX_DESCRIPTION_LENGTH = 5000 + MAX_TAGS = 500 # Total characters for all tags + MAX_FILE_SIZE = 256 * 1024 * 1024 * 1024 # 256 GB + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("youtube", {}) + self.access_token = None + + def authenticate(self) -> bool: + """Xác thực với YouTube API sử dụng refresh token. + + Cấu hình cần có: + - client_id + - client_secret + - refresh_token (lấy từ OAuth2 flow) + + Returns: + True nếu xác thực thành công. + """ + client_id = self.config.get("client_id", "") + client_secret = self.config.get("client_secret", "") + refresh_token = self.config.get("refresh_token", "") + + if not all([client_id, client_secret, refresh_token]): + print_substep( + "YouTube: Thiếu credentials (client_id, client_secret, refresh_token)", + style="bold red", + ) + return False + + try: + response = requests.post(self.TOKEN_URL, data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, timeout=30) + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data["access_token"] + self._authenticated = True + print_substep("YouTube: Xác thực thành công! ✅", style="bold green") + return True + + except Exception as e: + print_substep(f"YouTube: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên YouTube. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên YouTube, hoặc None nếu thất bại. + """ + if not self.access_token: + return None + + title = metadata.title[:self.MAX_TITLE_LENGTH] + description = self._build_description(metadata) + tags = metadata.tags or [] + + # Thêm hashtags vào description + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + description = f"{description}\n\n{hashtag_str}" + + # Video metadata + video_metadata = { + "snippet": { + "title": title, + "description": description[:self.MAX_DESCRIPTION_LENGTH], + "tags": tags, + "categoryId": self._get_category_id(metadata.category), + "defaultLanguage": metadata.language, + "defaultAudioLanguage": metadata.language, + }, + "status": { + "privacyStatus": metadata.privacy, + "selfDeclaredMadeForKids": False, + }, + } + + # Schedule publish time + if metadata.schedule_time and metadata.privacy != "public": + video_metadata["status"]["publishAt"] = metadata.schedule_time + video_metadata["status"]["privacyStatus"] = "private" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + # Step 1: Initiate resumable upload + params = { + "uploadType": "resumable", + "part": "snippet,status", + } + + init_response = requests.post( + self.UPLOAD_URL, + headers=headers, + params=params, + json=video_metadata, + timeout=30, + ) + init_response.raise_for_status() + + upload_url = init_response.headers.get("Location") + if not upload_url: + print_substep("YouTube: Không thể khởi tạo upload session", style="bold red") + return None + + # Step 2: Upload video file + file_size = os.path.getsize(metadata.file_path) + with open(metadata.file_path, "rb") as video_file: + upload_response = requests.put( + upload_url, + headers={ + "Content-Type": "video/mp4", + "Content-Length": str(file_size), + }, + data=video_file, + timeout=600, # 10 minutes timeout for large files + ) + upload_response.raise_for_status() + + video_data = upload_response.json() + video_id = video_data.get("id", "") + + if not video_id: + print_substep("YouTube: Upload thành công nhưng không lấy được video ID", style="bold yellow") + return None + + # Step 3: Upload thumbnail if available + if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path): + self._upload_thumbnail(video_id, metadata.thumbnail_path) + + video_url = f"https://www.youtube.com/watch?v={video_id}" + return video_url + + def _upload_thumbnail(self, video_id: str, thumbnail_path: str): + """Upload thumbnail cho video.""" + try: + url = f"https://www.googleapis.com/upload/youtube/v3/thumbnails/set" + with open(thumbnail_path, "rb") as thumb_file: + response = requests.post( + url, + headers={"Authorization": f"Bearer {self.access_token}"}, + params={"videoId": video_id}, + files={"media": thumb_file}, + timeout=60, + ) + response.raise_for_status() + print_substep("YouTube: Đã upload thumbnail ✅", style="bold green") + except Exception as e: + print_substep(f"YouTube: Lỗi upload thumbnail - {e}", style="bold yellow") + + def _build_description(self, metadata: VideoMetadata) -> str: + """Tạo description cho video YouTube.""" + parts = [] + if metadata.description: + parts.append(metadata.description) + parts.append("") + parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot") + parts.append(f"🌐 Ngôn ngữ: Tiếng Việt") + return "\n".join(parts) + + @staticmethod + def _get_category_id(category: str) -> str: + """Map category name to YouTube category ID.""" + categories = { + "Entertainment": "24", + "People & Blogs": "22", + "Comedy": "23", + "Education": "27", + "Science & Technology": "28", + "News & Politics": "25", + "Gaming": "20", + "Music": "10", + } + return categories.get(category, "24") diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 9b13657..0ac45e5 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -1,63 +1,107 @@ -[reddit.creds] -client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." } -client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." } -username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } -password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } -2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } +# ===== THREADS CONFIGURATION (thay thế Reddit cho thị trường Việt Nam) ===== +[threads.creds] +access_token = { optional = false, nmin = 10, explanation = "Threads API access token (lấy từ Meta Developer Portal)", example = "THR_abc123def456" } +user_id = { optional = false, nmin = 1, explanation = "Threads user ID của bạn", example = "12345678" } -[reddit.thread] -random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } -subreddit = { optional = false, regex = "[_0-9a-zA-Z\\+]+$", nmin = 3, explanation = "What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.", example = "AskReddit+Redditdev", oob_error = "A subreddit name HAS to be between 3 and 20 characters" } -post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" } -max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "max number of characters a comment can have. default is 500", example = 500, oob_error = "the max comment length should be between 10 and 10000" } -min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "min_comment_length number of characters a comment can have. default is 0", example = 50, oob_error = "the max comment length should be between 1 and 100" } -post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] } -min_comments = { default = 20, optional = false, nmin = 10, type = "int", explanation = "The minimum number of comments a post should have to be included. default is 20", example = 29, oob_error = "the minimum number of comments should be between 15 and 999999" } -blocked_words = { optional = true, default = "", type = "str", explanation = "Comma-separated list of words/phrases. Posts and comments containing any of these will be skipped.", example = "nsfw, spoiler, politics" } +[threads.thread] +target_user_id = { optional = true, default = "", explanation = "ID user muốn lấy threads. Để trống dùng user của bạn.", example = "87654321" } +post_id = { optional = true, default = "", explanation = "ID cụ thể của thread. Để trống để tự động chọn.", example = "18050000000000000" } +keywords = { optional = true, default = "", type = "str", explanation = "Từ khóa lọc threads, phân cách bằng dấu phẩy.", example = "viral, trending, hài hước" } +max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "Độ dài tối đa reply (ký tự). Mặc định: 500", example = 500, oob_error = "Phải trong khoảng 10-10000" } +min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "Độ dài tối thiểu reply. Mặc định: 1", example = 10 } +post_lang = { default = "vi", optional = true, explanation = "Ngôn ngữ. Mặc định: vi (Tiếng Việt)", example = "vi", options = ['vi', 'en', 'zh-CN', 'ja', 'ko', 'th', ''] } +min_comments = { default = 5, optional = false, nmin = 1, type = "int", explanation = "Số replies tối thiểu. Mặc định: 5", example = 5 } +blocked_words = { optional = true, default = "", type = "str", explanation = "Từ bị chặn, phân cách bằng dấu phẩy.", example = "spam, quảng cáo" } +channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN Stories", explanation = "Tên kênh hiển thị trên video" } [ai] -ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"} -ai_similarity_keywords = {optional = true, type="str", example= 'Elon Musk, Twitter, Stocks', explanation = "Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity"} +ai_similarity_enabled = { optional = true, option = [true, false], default = false, type = "bool", explanation = "Sắp xếp threads theo độ tương đồng với từ khóa" } +ai_similarity_keywords = { optional = true, type = "str", example = "viral, trending, hài hước", explanation = "Từ khóa sắp xếp threads theo độ tương đồng" } [settings] -allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" } -theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." } -times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." } -opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Sets the opacity of the comments when overlayed over the background", type = "float", nmin = 0, nmax = 1, oob_error = "The opacity HAS to be between 0 and 1", input_error = "The opacity HAS to be a decimal number between 0 and 1" } -#transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" } -storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, great for subreddits with stories" } -storymodemethod= { optional = true, default = 1, example = 1, explanation = "Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video ", type = "int", nmin = 0, oob_error = "It's very hard to run something less than once.", options = [0, 1] } -storymode_max_length = { optional = true, default = 1000, example = 1000, explanation = "Max length of the storymode video in characters. 200 characters are approximately 50 seconds.", type = "int", nmin = 1, oob_error = "It's very hard to make a video under a second." } -resolution_w = { optional = false, default = 1080, example = 1440, explantation = "Sets the width in pixels of the final video" } -resolution_h = { optional = false, default = 1920, example = 2560, explantation = "Sets the height in pixels of the final video" } -zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" } -channel_name = { optional = true, default = "Reddit Tales", example = "Reddit Stories", explanation = "Sets the channel name for the video" } +allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Cho phép nội dung NSFW" } +theme = { optional = false, default = "dark", example = "dark", options = ["dark", "light"], explanation = "Giao diện Threads: DARK hoặc LIGHT" } +times_to_run = { optional = false, default = 1, example = 2, explanation = "Số video tạo mỗi lần chạy", type = "int", nmin = 1, oob_error = "Phải >= 1" } +opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Độ trong suốt hình ảnh trên video", type = "float", nmin = 0, nmax = 1, oob_error = "Phải trong khoảng 0-1" } +storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Chế độ đọc truyện" } +storymodemethod = { optional = true, default = 1, example = 1, type = "int", nmin = 0, options = [0, 1], explanation = "Kiểu story mode. 0: 1 hình, 1: nhiều hình" } +storymode_max_length = { optional = true, default = 1000, example = 1000, type = "int", nmin = 1, explanation = "Độ dài tối đa story (ký tự)" } +resolution_w = { optional = false, default = 1080, example = 1080, explantation = "Chiều rộng video (pixels)" } +resolution_h = { optional = false, default = 1920, example = 1920, explantation = "Chiều cao video (pixels)" } +zoom = { optional = true, default = 1, example = 1.1, type = "float", nmin = 0.1, nmax = 2, explanation = "Mức zoom text" } +channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN", explanation = "Tên kênh" } [settings.background] -background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" } -background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" } -background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"} -enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"} -background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)" } -background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font family for the thumbnail text" } -background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Font size in pixels for the thumbnail text" } -background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" } +background_video = { optional = true, default = "minecraft", example = "minecraft", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2", "multiversus", "fall-guys", "steep", ""], explanation = "Video nền" } +background_audio = { optional = true, default = "lofi", example = "lofi", options = ["lofi", "lofi-2", "chill-summer", ""], explanation = "Nhạc nền" } +background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation = "Âm lượng nhạc nền (0-1)" } +enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation = "Tạo thêm video chỉ có TTS" } +background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Tạo thumbnail tự động" } +background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font thumbnail" } +background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Cỡ chữ thumbnail" } +background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Màu chữ thumbnail (RGB)" } [settings.tts] -voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " } -random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" } -elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] } -elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" } -aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } -streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } -tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" } -tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." } -python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" } -py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" } -silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" } -no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" } -openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "The API endpoint URL for OpenAI TTS generation" } -openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" } -openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] } -openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] } +voice_choice = { optional = false, default = "googletranslate", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "googletranslate", explanation = "Engine TTS. googletranslate hỗ trợ tiếng Việt tốt nhất" } +random_voice = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Ngẫu nhiên giọng đọc" } +elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam"], explanation = "Giọng ElevenLabs" } +elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "ElevenLabs API key" } +aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng AWS Polly" } +streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng Streamlabs Polly" } +tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "Giọng TikTok TTS" } +tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok session ID" } +python_voice = { optional = false, default = "1", example = "1", explanation = "Index giọng hệ thống" } +py_voice_num = { optional = false, default = "2", example = "2", explanation = "Số giọng hệ thống" } +silence_duration = { optional = true, example = "0.1", default = 0.3, type = "float", explanation = "Khoảng lặng giữa TTS (giây)" } +no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Loại bỏ emoji" } +openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "OpenAI API URL" } +openai_api_key = { optional = true, example = "sk-abc123...", explanation = "OpenAI API key" } +openai_voice_name = { optional = false, default = "alloy", example = "alloy", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"], explanation = "Giọng OpenAI TTS" } +openai_model = { optional = false, default = "tts-1", example = "tts-1", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], explanation = "Model OpenAI TTS" } + +# ===== AUTO-UPLOAD PLATFORMS ===== + +[uploaders.youtube] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload YouTube" } +client_id = { optional = true, default = "", explanation = "Google OAuth2 Client ID" } +client_secret = { optional = true, default = "", explanation = "Google OAuth2 Client Secret" } +refresh_token = { optional = true, default = "", explanation = "Google OAuth2 Refresh Token" } + +[uploaders.tiktok] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload TikTok" } +client_key = { optional = true, default = "", explanation = "TikTok Client Key" } +client_secret = { optional = true, default = "", explanation = "TikTok Client Secret" } +refresh_token = { optional = true, default = "", explanation = "TikTok Refresh Token" } + +[uploaders.facebook] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload Facebook" } +page_id = { optional = true, default = "", explanation = "Facebook Page ID" } +access_token = { optional = true, default = "", explanation = "Facebook Page Access Token" } + +# ===== SCHEDULER ===== + +[scheduler] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật lên lịch tự động" } +cron = { optional = true, default = "0 */6 * * *", explanation = "Cron expression. Mặc định: mỗi 6 giờ", example = "0 8,14,20 * * *" } +timezone = { optional = true, default = "Asia/Ho_Chi_Minh", explanation = "Múi giờ", example = "Asia/Ho_Chi_Minh" } +max_videos_per_day = { optional = true, default = 4, type = "int", nmin = 1, nmax = 20, explanation = "Số video tối đa/ngày" } + +# ===== LEGACY REDDIT CONFIG ===== + +[reddit.creds] +client_id = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client ID" } +client_secret = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client secret" } +username = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit username" } +password = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit password" } +2fa = { optional = true, type = "bool", options = [true, false], default = false, explanation = "(Legacy) Reddit 2FA" } + +[reddit.thread] +random = { optional = true, options = [true, false], default = false, type = "bool", explanation = "(Legacy)" } +subreddit = { optional = true, default = "AskReddit", nmin = 0, explanation = "(Legacy)" } +post_id = { optional = true, default = "", explanation = "(Legacy)" } +max_comment_length = { default = 500, optional = true, nmin = 10, nmax = 10000, type = "int", explanation = "(Legacy)" } +min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "(Legacy)" } +post_lang = { default = "vi", optional = true, explanation = "(Legacy)", options = ['','vi','af','ak','am','ar','as','ay','az','be','bg','bho','bm','bn','bs','ca','ceb','ckb','co','cs','cy','da','de','doi','dv','ee','el','en','en-US','eo','es','et','eu','fa','fi','fr','fy','ga','gd','gl','gn','gom','gu','ha','haw','hi','hmn','hr','ht','hu','hy','id','ig','ilo','is','it','iw','ja','jw','ka','kk','km','kn','ko','kri','ku','ky','la','lb','lg','ln','lo','lt','lus','lv','mai','mg','mi','mk','ml','mn','mni-Mtei','mr','ms','mt','my','ne','nl','no','nso','ny','om','or','pa','pl','ps','pt','qu','ro','ru','rw','sa','sd','si','sk','sl','sm','sn','so','sq','sr','st','su','sv','sw','ta','te','tg','th','ti','tk','tl','tr','ts','tt','ug','uk','ur','uz','vi','xh','yi','yo','zh-CN','zh-TW','zu'] } +min_comments = { default = 20, optional = true, nmin = 1, type = "int", explanation = "(Legacy)" } +blocked_words = { optional = true, default = "", type = "str", explanation = "(Legacy)" } diff --git a/utils/videos.py b/utils/videos.py index 7c756fc..cf188bf 100755 --- a/utils/videos.py +++ b/utils/videos.py @@ -1,34 +1,38 @@ import json import time -from praw.models import Submission - from utils import settings from utils.console import print_step def check_done( - redditobj: Submission, -) -> Submission: + redditobj, +): # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack """Checks if the chosen post has already been generated Args: - redditobj (Submission): Reddit object gotten from reddit/subreddit.py + redditobj: Reddit/Threads submission object Returns: - Submission|None: Reddit object in args + The object if not done, None if already done """ with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: done_videos = json.load(done_vids_raw) for video in done_videos: if video["id"] == str(redditobj): - if settings.config["reddit"]["thread"]["post_id"]: + # Check both threads and reddit config for post_id + post_id = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + post_id = settings.config["threads"]["thread"].get("post_id", "") + if not post_id and "reddit" in settings.config and "thread" in settings.config["reddit"]: + post_id = settings.config["reddit"]["thread"].get("post_id", "") + if post_id: print_step( - "You already have done this video but since it was declared specifically in the config file the program will continue" + "Video đã được tạo trước đó nhưng được chỉ định cụ thể trong config, tiếp tục..." ) return redditobj - print_step("Getting new post as the current one has already been done") + print_step("Đang lấy bài viết mới vì bài này đã được tạo video") return None return redditobj diff --git a/video_creation/final_video.py b/video_creation/final_video.py index c4f3a0b..621ad4a 100644 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -75,7 +75,12 @@ def name_normalize(name: str) -> str: name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name) name = re.sub(r"\/", r"", name) - lang = settings.config["reddit"]["thread"]["post_lang"] + # Support both Threads and Reddit config + lang = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") + if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") if lang: print_substep("Translating filename...") translated_name = translators.translate_text(name, translator="google", to_language=lang) @@ -359,7 +364,12 @@ def make_final_video( title_thumb = reddit_obj["thread_title"] filename = f"{name_normalize(title)[:251]}" - subreddit = settings.config["reddit"]["thread"]["subreddit"] + # Support both Threads and Reddit config for subreddit/channel name + subreddit = "threads" + if "threads" in settings.config and "thread" in settings.config["threads"]: + subreddit = settings.config["threads"]["thread"].get("channel_name", "threads") + elif "reddit" in settings.config and "thread" in settings.config["reddit"]: + subreddit = settings.config["reddit"]["thread"].get("subreddit", "threads") if not exists(f"./results/{subreddit}"): print_substep("The 'results' folder could not be found so it was automatically created.") diff --git a/video_creation/threads_screenshot.py b/video_creation/threads_screenshot.py new file mode 100644 index 0000000..aaea398 --- /dev/null +++ b/video_creation/threads_screenshot.py @@ -0,0 +1,344 @@ +""" +Threads Screenshot Generator - Tạo hình ảnh giả lập giao diện Threads. + +Sử dụng Pillow để render hình ảnh thay vì chụp screenshot từ trình duyệt, +vì Threads không có giao diện web tĩnh dễ chụp như Reddit. +""" + +import os +import re +import textwrap +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFont +from rich.progress import track + +from utils import settings +from utils.console import print_step, print_substep + + +# Threads color themes +THEMES = { + "dark": { + "bg_color": (0, 0, 0), + "card_bg": (30, 30, 30), + "text_color": (255, 255, 255), + "secondary_text": (140, 140, 140), + "border_color": (50, 50, 50), + "accent_color": (0, 149, 246), # Threads blue + "reply_line": (60, 60, 60), + }, + "light": { + "bg_color": (255, 255, 255), + "card_bg": (255, 255, 255), + "text_color": (0, 0, 0), + "secondary_text": (130, 130, 130), + "border_color": (219, 219, 219), + "accent_color": (0, 149, 246), + "reply_line": (200, 200, 200), + }, +} + + +def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + """Load font hỗ trợ tiếng Việt.""" + font_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts") + if bold: + font_path = os.path.join(font_dir, "Roboto-Bold.ttf") + else: + font_path = os.path.join(font_dir, "Roboto-Medium.ttf") + + try: + return ImageFont.truetype(font_path, size) + except (OSError, IOError): + return ImageFont.load_default() + + +def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: + """Wrap text to fit within max_width pixels.""" + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = font.getbbox(test_line) + if bbox[2] <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines if lines else [""] + + +def _draw_avatar(draw: ImageDraw.Draw, x: int, y: int, size: int, color: Tuple[int, ...]): + """Vẽ avatar tròn placeholder.""" + draw.ellipse([x, y, x + size, y + size], fill=color) + # Vẽ chữ cái đầu trong avatar + font = _get_font(size // 2, bold=True) + draw.text( + (x + size // 4, y + size // 6), + "T", + fill=(255, 255, 255), + font=font, + ) + + +def create_thread_post_image( + thread_obj: dict, + theme_name: str = "dark", + width: int = 1080, +) -> Image.Image: + """Tạo hình ảnh cho bài viết Threads chính (title/post). + + Args: + thread_obj: Thread object chứa thông tin bài viết. + theme_name: Theme name ("dark" hoặc "light"). + width: Chiều rộng hình ảnh. + + Returns: + PIL Image object. + """ + theme = THEMES.get(theme_name, THEMES["dark"]) + + padding = 40 + content_width = width - (padding * 2) + avatar_size = 60 + + # Fonts + username_font = _get_font(28, bold=True) + body_font = _get_font(32) + meta_font = _get_font(22) + + author = thread_obj.get("thread_author", "@user") + text = thread_obj.get("thread_title", thread_obj.get("thread_post", "")) + + # Tính chiều cao + text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30) + line_height = 42 + text_height = len(text_lines) * line_height + + total_height = padding + avatar_size + 20 + text_height + 60 + padding + + # Tạo image + img = Image.new("RGBA", (width, total_height), theme["bg_color"]) + draw = ImageDraw.Draw(img) + + y_cursor = padding + + # Avatar + _draw_avatar(draw, padding, y_cursor, avatar_size, theme["accent_color"]) + + # Username + draw.text( + (padding + avatar_size + 15, y_cursor + 5), + author, + fill=theme["text_color"], + font=username_font, + ) + + # Timestamp + draw.text( + (padding + avatar_size + 15, y_cursor + 35), + "🧵 Threads", + fill=theme["secondary_text"], + font=meta_font, + ) + + y_cursor += avatar_size + 20 + + # Thread line (vertical line from avatar to content) + line_x = padding + avatar_size // 2 + draw.line( + [(line_x, padding + avatar_size + 5), (line_x, y_cursor - 5)], + fill=theme["reply_line"], + width=3, + ) + + # Body text + for line in text_lines: + draw.text( + (padding + 10, y_cursor), + line, + fill=theme["text_color"], + font=body_font, + ) + y_cursor += line_height + + # Interaction bar + y_cursor += 20 + icons = "❤️ 💬 🔄 ✈️" + draw.text( + (padding + 10, y_cursor), + icons, + fill=theme["secondary_text"], + font=meta_font, + ) + + return img + + +def create_comment_image( + comment: dict, + index: int, + theme_name: str = "dark", + width: int = 1080, +) -> Image.Image: + """Tạo hình ảnh cho một reply/comment trên Threads. + + Args: + comment: Comment dict. + index: Số thứ tự comment. + theme_name: Theme name. + width: Chiều rộng hình ảnh. + + Returns: + PIL Image object. + """ + theme = THEMES.get(theme_name, THEMES["dark"]) + + padding = 40 + content_width = width - (padding * 2) + avatar_size = 50 + + # Fonts + username_font = _get_font(24, bold=True) + body_font = _get_font(30) + meta_font = _get_font(20) + + author = comment.get("comment_author", f"@user{index}") + text = comment.get("comment_body", "") + + # Tính chiều cao + text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30) + line_height = 40 + text_height = len(text_lines) * line_height + + total_height = padding + avatar_size + 15 + text_height + 40 + padding + + # Tạo image + img = Image.new("RGBA", (width, total_height), theme["bg_color"]) + draw = ImageDraw.Draw(img) + + y_cursor = padding + + # Reply line at top + draw.line( + [(padding, 0), (padding, y_cursor)], + fill=theme["reply_line"], + width=2, + ) + + # Avatar (smaller for comments) + colors = [ + (88, 101, 242), # Blue + (237, 66, 69), # Red + (87, 242, 135), # Green + (254, 231, 92), # Yellow + (235, 69, 158), # Pink + ] + avatar_color = colors[index % len(colors)] + _draw_avatar(draw, padding, y_cursor, avatar_size, avatar_color) + + # Username + draw.text( + (padding + avatar_size + 12, y_cursor + 5), + author, + fill=theme["text_color"], + font=username_font, + ) + + # Time indicator + draw.text( + (padding + avatar_size + 12, y_cursor + 30), + "Trả lời", + fill=theme["secondary_text"], + font=meta_font, + ) + + y_cursor += avatar_size + 15 + + # Body text + for line in text_lines: + draw.text( + (padding + 10, y_cursor), + line, + fill=theme["text_color"], + font=body_font, + ) + y_cursor += line_height + + # Bottom separator + y_cursor += 10 + draw.line( + [(padding, y_cursor), (width - padding, y_cursor)], + fill=theme["border_color"], + width=1, + ) + + return img + + +def get_screenshots_of_threads_posts(thread_object: dict, screenshot_num: int): + """Tạo screenshots cho bài viết Threads. + + Thay thế get_screenshots_of_reddit_posts() cho Threads. + + Args: + thread_object: Thread object từ threads_client.py. + screenshot_num: Số lượng screenshots cần tạo. + """ + W: Final[int] = int(settings.config["settings"]["resolution_w"]) + H: Final[int] = int(settings.config["settings"]["resolution_h"]) + theme: str = settings.config["settings"].get("theme", "dark") + storymode: bool = settings.config["settings"].get("storymode", False) + + print_step("Đang tạo hình ảnh cho bài viết Threads...") + + thread_id = re.sub(r"[^\w\s-]", "", thread_object["thread_id"]) + Path(f"assets/temp/{thread_id}/png").mkdir(parents=True, exist_ok=True) + + # Tạo hình ảnh cho bài viết chính (title) + title_img = create_thread_post_image( + thread_object, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + title_img.save(f"assets/temp/{thread_id}/png/title.png") + print_substep("Đã tạo hình ảnh tiêu đề", style="bold green") + + if storymode: + # Story mode - chỉ cần 1 hình cho toàn bộ nội dung + story_img = create_thread_post_image( + { + "thread_author": thread_object.get("thread_author", "@user"), + "thread_title": thread_object.get("thread_post", ""), + }, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + story_img.save(f"assets/temp/{thread_id}/png/story_content.png") + else: + # Comment mode - tạo hình cho từng reply + comments = thread_object.get("comments", [])[:screenshot_num] + for idx, comment in enumerate( + track(comments, "Đang tạo hình ảnh replies...") + ): + if idx >= screenshot_num: + break + + comment_img = create_comment_image( + comment, + index=idx, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + comment_img.save(f"assets/temp/{thread_id}/png/comment_{idx}.png") + + print_substep("Đã tạo tất cả hình ảnh thành công! ✅", style="bold green")