From 2ff61ec4ab30a8e5d076df77b7b190bd22fcdcd8 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 25 Aug 2025 10:28:46 +0100 Subject: [PATCH] Added functionality to allow users to paste the link of a specific post to generate a video from --- target.py | 291 ++++++++++++++++++++++++ utils/imagenarator.py | 120 ++++++++++ video_creation/screenshot_downloader.py | 9 +- 3 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 target.py diff --git a/target.py b/target.py new file mode 100644 index 0000000..dce91f1 --- /dev/null +++ b/target.py @@ -0,0 +1,291 @@ +import math +import re +import sys +from os import name +from pathlib import Path +from subprocess import Popen +from typing import NoReturn + +import praw +from prawcore.exceptions 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.version import checkversion +from video_creation.background import download_background_audio, download_background_video +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 +from video_creation.background import chop_background, get_background_config + +__VERSION__ = "3.3.0" + +print_markdown( + """# Reddit Video Maker Bot + +██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ +██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ████╗ ████║██╔══██╗ +██║ ██╔╝██╔════╝██╔══██╗██████╔╝█████╗ ██║ ██║██║ ██║██║██║ ██║█████╗ ██║ ██║ ██╔████╔██║███████║ +█████╔╝ █████╗ ██████╔╝██████╔╝██╔══╝ ██║ ██║██║ ██║██║██║ ██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║ +██╔═██╗ ██╔══╝ ██╔══██╗██╔═██╗ ██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║ ██║███████╗╚██████╔╝ ██║ ╚═╝ ██║██║ ██║ +██║ ██╗███████╗██║ ██║██║ ██╗███████╗██║ ██║ ╚████╔╝ ██║██████╔╝╚══════╝ ╚═════╝ ██║ ██║╚═╝ ██║ +╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ +╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝""" +) +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/" +) +checkversion(__VERSION__) + + +def extract_post_id_from_url(url: str) -> str: + """ + Extract the post ID from a Reddit URL. + + Args: + url (str): Reddit post URL + + Returns: + str: Post ID + """ + # Handle different Reddit URL formats + patterns = [ + r'reddit\.com/r/\w+/comments/(\w+)/', # Standard format + r'reddit\.com/comments/(\w+)/', # Short format + r'reddit\.com/r/\w+/comments/(\w+)', # Without trailing slash + r'reddit\.com/comments/(\w+)', # Short format without trailing slash + ] + + for pattern in patterns: + match = re.search(pattern, url) + if match: + return match.group(1) + + raise ValueError("Invalid Reddit URL format. Please provide a valid Reddit post URL.") + + +def get_post_from_url(url: str) -> dict: + """ + Get Reddit post data from a specific URL. + + Args: + url (str): Reddit post URL + + Returns: + dict: Reddit post data + """ + print_substep("Extracting post ID from URL...") + + try: + post_id = extract_post_id_from_url(url) + print_substep(f"Post ID extracted: {post_id}") + except ValueError as e: + print_substep(f"Error: {e}", style="red") + sys.exit(1) + + print_substep("Logging into Reddit...") + + # Setup Reddit authentication + if settings.config["reddit"]["creds"]["2fa"]: + print("\nEnter your two-factor authentication code from your authenticator app.\n") + code = input("> ") + print() + pw = settings.config["reddit"]["creds"]["password"] + passkey = f"{pw}:{code}" + else: + passkey = settings.config["reddit"]["creds"]["password"] + + username = settings.config["reddit"]["creds"]["username"] + if str(username).casefold().startswith("u/"): + username = username[2:] + + try: + reddit = praw.Reddit( + client_id=settings.config["reddit"]["creds"]["client_id"], + client_secret=settings.config["reddit"]["creds"]["client_secret"], + user_agent="Accessing Reddit threads", + username=username, + passkey=passkey, + check_for_async=False, + ) + except ResponseException as e: + if e.response.status_code == 401: + print("Invalid credentials - please check them in config.toml") + sys.exit(1) + except Exception as e: + print(f"Something went wrong: {e}") + sys.exit(1) + + print_substep("Fetching post data...") + + try: + submission = reddit.submission(id=post_id) + # Force the submission to load all data + submission.title + submission.selftext + submission.score + submission.upvote_ratio + submission.num_comments + submission.permalink + submission.over_18 + submission.is_self + except Exception as e: + print_substep(f"Error fetching post: {e}", style="red") + sys.exit(1) + + # Check if post is NSFW + if submission.over_18 and not settings.config["settings"]["allow_nsfw"]: + print_substep("This post is NSFW and NSFW content is not allowed in your config.", style="red") + sys.exit(1) + + # Check if post has content for story mode + if settings.config["settings"]["storymode"]: + if not submission.selftext: + print_substep("This post has no text content for story mode.", style="red") + sys.exit(1) + + if len(submission.selftext) > settings.config["settings"]["storymode_max_length"]: + print_substep(f"Post is too long ({len(submission.selftext)} characters). Max allowed: {settings.config['settings']['storymode_max_length']}", style="red") + sys.exit(1) + + if len(submission.selftext) < 30: + print_substep("Post is too short (less than 30 characters).", style="red") + sys.exit(1) + + # Build content dictionary + content = { + "thread_url": f"https://new.reddit.com{submission.permalink}", + "thread_title": submission.title, + "thread_id": submission.id, + "is_nsfw": submission.over_18, + "comments": [] + } + + # Process content based on story mode + if settings.config["settings"]["storymode"]: + if settings.config["settings"]["storymodemethod"] == 1: + from utils.posttextparser import posttextparser + content["thread_post"] = posttextparser(submission.selftext) + else: + content["thread_post"] = submission.selftext + else: + # Process comments (not used in story mode) + for top_level_comment in submission.comments: + if hasattr(top_level_comment, 'body') and top_level_comment.body not in ["[removed]", "[deleted]"]: + if not top_level_comment.stickied: + from utils.voice import sanitize_text + sanitised = sanitize_text(top_level_comment.body) + if sanitised and sanitised != " ": + if len(top_level_comment.body) <= int(settings.config["reddit"]["thread"]["max_comment_length"]): + if len(top_level_comment.body) >= int(settings.config["reddit"]["thread"]["min_comment_length"]): + if top_level_comment.author is not None: + content["comments"].append({ + "comment_body": top_level_comment.body, + "comment_url": top_level_comment.permalink, + "comment_id": top_level_comment.id, + }) + + # Display post information + print_substep(f"Video will be: {submission.title} 👍", style="bold green") + print_substep(f"Thread url is: {content['thread_url']} 👍", style="bold green") + print_substep(f"Thread has {submission.score} upvotes", style="bold blue") + print_substep(f"Thread has a upvote ratio of {submission.upvote_ratio * 100}%", style="bold blue") + print_substep(f"Thread has {submission.num_comments} comments", style="bold blue") + + if settings.config["settings"]["storymode"]: + print_substep(f"Post content length: {len(submission.selftext)} characters", style="bold blue") + + print_substep("Post data fetched successfully.", style="bold green") + return content + + +def main() -> None: + """Main function to process a specific Reddit post URL.""" + global redditid, reddit_object + + print_step("Reddit Video Maker Bot - Target Mode") + print_substep("This mode allows you to create a video from a specific Reddit post URL.") + + # Get URL from user + while True: + url = input("\nEnter the Reddit post URL: ").strip() + if url: + break + print_substep("Please enter a valid URL.", style="red") + + # Get post data + reddit_object = get_post_from_url(url) + redditid = id(reddit_object) + + # Process the post + length, number_of_comments = save_text_to_mp3(reddit_object) + length = math.ceil(length) + + get_screenshots_of_reddit_posts(reddit_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) + + +def shutdown() -> NoReturn: + """Cleanup and exit.""" + if "redditid" in globals(): + print_markdown("## Clearing temp files") + cleanup(redditid) + + print("Exiting...") + sys.exit() + + +if __name__ == "__main__": + if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12, 13]: + 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." + ) + sys.exit() + + ffmpeg_install() + directory = Path().absolute() + config = settings.check_toml( + f"{directory}/utils/.config.template.toml", f"{directory}/config.toml" + ) + config is False and sys.exit() + + if ( + not settings.config["settings"]["tts"]["tiktok_sessionid"] + or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" + ) and config["settings"]["tts"]["voice_choice"] == "tiktok": + print_substep( + "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", + "bold red", + ) + sys.exit() + + try: + main() + 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" + 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"]}' + ) + raise err diff --git a/utils/imagenarator.py b/utils/imagenarator.py index c69dd28..22830ba 100644 --- a/utils/imagenarator.py +++ b/utils/imagenarator.py @@ -76,6 +76,126 @@ def draw_multiple_line_text( y += line_height + padding +def draw_highlighted_text( + image, text, font, padding, wrap=50, highlighted_word_index=-1 +) -> None: + """ + Draw text with white fill, black outline, and yellow inner outline (layered effect) + """ + draw = ImageDraw.Draw(image) + image_width, image_height = image.size + + # Split text into words and wrap + words = text.split() + lines = [] + current_line = [] + current_line_width = 0 + + for word in words: + word_width, _ = getsize(font, word + " ") + if current_line_width + word_width <= image_width - 40: # 20px padding on each side + current_line.append(word) + current_line_width += word_width + else: + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_line_width = word_width + + if current_line: + lines.append(" ".join(current_line)) + + # Calculate total height + line_height = getheight(font, "A") + total_height = len(lines) * line_height + (len(lines) - 1) * padding + y_start = (image_height - total_height) // 2 + + # Draw each line + word_index = 0 + for line_idx, line in enumerate(lines): + line_words = line.split() + x = (image_width - getsize(font, line)[0]) // 2 + y = y_start + line_idx * (line_height + padding) + + # Draw each word in the line + for word in line_words: + word_width, _ = getsize(font, word + " ") + + # Determine color based on highlighting + if highlighted_word_index >= 0 and word_index == highlighted_word_index: + text_color = (255, 255, 0) # Bright yellow for highlighted word + inner_outline_color = (255, 255, 255) # White inner outline for highlighted word + else: + text_color = (255, 255, 255) # White for other words + inner_outline_color = (255, 255, 0) # Yellow inner outline for non-highlighted words + + # Draw black outer outline (thickest) + outer_stroke_width = 4 + for dx in range(-outer_stroke_width, outer_stroke_width + 1): + for dy in range(-outer_stroke_width, outer_stroke_width + 1): + if dx != 0 or dy != 0: # Skip the center pixel + draw.text( + (x + dx, y + dy), + word, + font=font, + fill=(0, 0, 0) # Black outer outline + ) + + # Draw yellow inner outline (medium thickness) + inner_stroke_width = 2 + for dx in range(-inner_stroke_width, inner_stroke_width + 1): + for dy in range(-inner_stroke_width, inner_stroke_width + 1): + if dx != 0 or dy != 0: # Skip the center pixel + draw.text( + (x + dx, y + dy), + word, + font=font, + fill=inner_outline_color # Yellow inner outline + ) + + # Draw the main text (white or yellow) + draw.text((x, y), word, font=font, fill=text_color) + + x += word_width + word_index += 1 + + +def create_highlighted_captions(theme, reddit_obj: dict, padding=5) -> None: + """ + Create captions with white text and black stroke (highlighted style) + """ + texts = reddit_obj["thread_post"] + id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) + + # Use the actual video resolution from config + W = int(settings.config["settings"]["resolution_w"]) + H = int(settings.config["settings"]["resolution_h"]) + size = (W, H) + + # Use larger, bolder font for better visibility like the screenshot + font_size = min(80, max(40, H // 25)) # Larger font size for better impact + font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_size) + + for idx, text in track(enumerate(texts), "Creating Highlighted Captions"): + text = process_text(text, False) + + # Create transparent background + image = Image.new("RGBA", size, (0, 0, 0, 0)) + + # Draw highlighted text with white text and black stroke + draw_highlighted_text( + image, + text, + font, + padding=padding, + wrap=25, # Tighter wrap for better text flow + highlighted_word_index=-1 # No specific word highlighted, just the style + ) + + # Save the image + image.save(f"assets/temp/{id}/png/img{idx}.png") + + def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None: """ Render Images for video diff --git a/video_creation/screenshot_downloader.py b/video_creation/screenshot_downloader.py index 8458c54..423ed1e 100644 --- a/video_creation/screenshot_downloader.py +++ b/video_creation/screenshot_downloader.py @@ -9,7 +9,7 @@ from rich.progress import track from utils import settings from utils.console import print_step, print_substep -from utils.imagenarator import imagemaker +from utils.imagenarator import imagemaker, create_highlighted_captions from utils.playwright import clear_cookie_by_name from utils.videos import save_data @@ -61,12 +61,11 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): if storymode and settings.config["settings"]["storymodemethod"] == 1: # for idx,item in enumerate(reddit_object["thread_post"]): - print_substep("Generating images...") - return imagemaker( + print_substep("Generating highlighted captions...") + return create_highlighted_captions( theme=bgcolor, reddit_obj=reddit_object, - txtclr=txtcolor, - transparent=transparent, + padding=5, ) screenshot_num: int