pull/2395/merge
Dev Bredda 2 weeks ago committed by GitHub
commit 792591ad74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -15,7 +15,7 @@ from utils.console import print_step, print_substep
from utils.voice import sanitize_text
DEFAULT_MAX_LENGTH: int = (
50 # Video length variable, edit this on your own risk. It should work, but it's not supported
300 # Video length variable, edit this on your own risk. It should work, but it's not supported
)
@ -144,11 +144,20 @@ class TTSEngine:
print("OSError")
def call_tts(self, filename: str, text: str):
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
random_voice=settings.config["settings"]["tts"]["random_voice"],
)
# Check if the TTS module supports random_voice parameter
import inspect
run_signature = inspect.signature(self.tts_module.run)
if 'random_voice' in run_signature.parameters:
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
random_voice=settings.config["settings"]["tts"]["random_voice"],
)
else:
self.tts_module.run(
text,
filepath=f"{self.path}/{filename}.mp3",
)
# try:
# self.length += MP3(f"{self.path}/{filename}.mp3").info.length
# except (MutagenError, HeaderNotFoundError):

@ -79,9 +79,9 @@ def shutdown() -> NoReturn:
if __name__ == "__main__":
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11]:
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."
"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()

@ -2,7 +2,7 @@ boto3==1.34.127
botocore==1.34.127
gTTS==2.5.1
moviepy==1.0.3
playwright==1.44.0
playwright>=1.45.0
praw==7.7.1
prawcore~=2.3.0
requests==2.32.3
@ -10,15 +10,11 @@ rich==13.7.1
toml==0.10.2
translators==5.9.2
pyttsx3==2.90
Pillow==10.3.0
Pillow>=10.4.0
tomlkit==0.12.5
Flask==3.0.3
clean-text==0.6.0
unidecode==1.3.8
spacy==3.7.5
torch==2.3.1
transformers==4.41.2
ffmpeg-python==0.2.0
elevenlabs==1.3.0
yt-dlp==2024.5.27
numpy==1.26.4

@ -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

@ -7,6 +7,29 @@ from rich.progress import track
from TTS.engine_wrapper import process_text
from utils.fonts import getheight, getsize
from utils import settings
def calculate_text_dimensions(text, font, padding, wrap=50):
"""
Calculate the dimensions needed for text with given font and padding
"""
lines = textwrap.wrap(text, width=wrap)
max_line_width = 0
total_height = 0
for line in lines:
line_width, line_height = getsize(font, line)
max_line_width = max(max_line_width, line_width)
total_height += line_height
# Add padding between lines
if len(lines) > 1:
total_height += (len(lines) - 1) * padding
# Add minimal padding around the text
padding_around = 10
return max_line_width + (padding_around * 2), total_height + (padding_around * 2)
def draw_multiple_line_text(
@ -53,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
@ -60,16 +203,33 @@ def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) ->
texts = reddit_obj["thread_post"]
id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])
# Use the actual video resolution from config instead of fixed landscape size
W = int(settings.config["settings"]["resolution_w"])
H = int(settings.config["settings"]["resolution_h"])
size = (W, H)
# Adjust font size based on video resolution for better readability
# For 9:16 portrait videos, use smaller font size to fit better in the compact background
if transparent:
font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100)
font_size = min(50, max(25, H // 50)) # Smaller font size for compact background
font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_size)
else:
font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 100)
size = (1920, 1080)
font_size = min(50, max(25, H // 50)) # Smaller font size for compact background
font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), font_size)
image = Image.new("RGBA", size, theme)
for idx, text in track(enumerate(texts), "Rendering Image"):
image = Image.new("RGBA", size, theme)
text = process_text(text, False)
draw_multiple_line_text(image, text, font, txtclr, padding, wrap=30, transparent=transparent)
# Adjust text wrapping based on video width for better fit
wrap_width = max(20, min(35, W // 60)) # More balanced wrap width to fill the overlay better
# Calculate the dimensions needed for this text
text_width, text_height = calculate_text_dimensions(text, font, padding=2, wrap=wrap_width)
# Create an image that's only as big as the text content
image = Image.new("RGBA", (text_width, text_height), theme)
# Use smaller padding to make text lines closer together and fill more vertical space
draw_multiple_line_text(image, text, font, txtclr, padding=2, wrap=wrap_width, transparent=transparent)
image.save(f"assets/temp/{id}/png/img{idx}.png")

@ -86,9 +86,10 @@ def download_background_video(background_config: Tuple[str, str, str, Any]):
print_substep("Downloading the backgrounds videos... please be patient 🙏 ")
print_substep(f"Downloading {filename} from {uri}")
ydl_opts = {
"format": "bestvideo[height<=1080][ext=mp4]",
"format": "best[height<=1080]/best",
"outtmpl": f"assets/backgrounds/video/{credit}-{filename}",
"retries": 10,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:

@ -254,7 +254,11 @@ def make_final_video(
console.log(f"[bold green] Video Will Be: {length} Seconds Long")
screenshot_width = int((W * 45) // 100)
# For 9:16 portrait videos, use an even smaller width to completely prevent clipping
# Since W=1080 and H=1920, we need to be extremely conservative with the width
screenshot_width = int((W * 15) // 100) # Use only 15% of video width (162px)
# Ensure minimum and maximum bounds for portrait videos with extra padding
screenshot_width = max(150, min(screenshot_width, W - 300)) # Min 150px, Max W-300px for extra generous padding
audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3")
final_audio = merge_background_audio(audio, reddit_id)

@ -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
@ -79,12 +78,13 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):
# Device scale factor (or dsf for short) allows us to increase the resolution of the screenshots
# When the dsf is 1, the width of the screenshot is 600 pixels
# so we need a dsf such that the width of the screenshot is greater than the final resolution of the video
dsf = (W // 600) + 1
# For better scaling, use a more conservative approach
dsf = max(1, min(2, (W // 800) + 1)) # Cap dsf between 1 and 2 for better compatibility
context = browser.new_context(
locale=lang or "en-us",
color_scheme="dark",
viewport=ViewportSize(width=W, height=H),
viewport=ViewportSize(width=min(W, 1200), height=min(H, 1600)), # Cap viewport size
device_scale_factor=dsf,
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
)
@ -131,7 +131,7 @@ def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):
page.reload()
# Get the thread screenshot
page.goto(reddit_object["thread_url"], timeout=0)
page.set_viewport_size(ViewportSize(width=W, height=H))
page.set_viewport_size(ViewportSize(width=min(W, 1200), height=min(H, 1600)))
page.wait_for_load_state()
page.wait_for_timeout(5000)

Loading…
Cancel
Save