├── .gitignore ├── README.md ├── config.example.ini ├── main.py ├── markdown_to_text.py ├── reddit.py ├── screenshot.py ├── videoscript.py ├── voiceover.py └── youtube.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/* 2 | BackgroundVideos/* 3 | OutputVideos/* 4 | Screenshots/* 5 | Voiceovers/* 6 | Lib/* 7 | Scripts/* 8 | metadata.json 9 | pyvenv.cfg 10 | client_secrets.json 11 | geckodriver.log 12 | config.ini -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reddit Video Generator 2 | 3 | *This project is further explained in [this video](https://youtu.be/ZmSb3LZDdf0)* 4 | 5 | *This code is meant only for educational reference and will not be maintained. Because of this, the repo is archived and is in a read-only state* 6 | 7 | --- 8 | This program generates a .mp4 video automatically by querying the top post on the 9 | r/askreddit subreddit, and grabbing several comments. The workflow of this program is: 10 | - Install dependencies 11 | - Make a copy of config.example.ini and rename to config.ini 12 | - Register with Reddit to create an application [here](https://www.reddit.com/prefs/apps/) and copy the credentials 13 | - Use the credentials from the previous step to update config.ini (lines 22 -> 24) 14 | 15 | Now, you can run `python main.py` to be prompted for which post to choose. Alternatively, 16 | you can run `python main.py ` to create a video for a specific post. 17 | -------------------------------------------------------------------------------- /config.example.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | PreviewBeforeUpload = yes 3 | VLCPath = C:/Program Files/VideoLAN/VLC/vlc.exe 4 | OutputDirectory = OutputVideos 5 | BackgroundDirectory = BackgroundVideos 6 | # Example video file name: ShortTemplate_1.mp4 7 | BackgroundFilePrefix = ShortTemplate_ 8 | 9 | [Video] 10 | # Total spacing between screenshot and edge of frame (width) 11 | # ex. 64 = 32 pixels of space on each side 12 | MarginSize = 64 13 | Bitrate = 8000k 14 | Threads = 12 15 | 16 | [Reddit] 17 | # For fully-automatic video creation, use 0. 18 | # Otherwise, you will be prompted from cmd to 19 | # select which post (from last 24 hours) you want to create a video on 20 | NumberOfPostsToSelectFrom = 4 21 | 22 | CLIENT_ID = YOUR_APP_ID 23 | CLIENT_SECRET = YOUR_CLIENT_SECRET 24 | USER_AGENT = Platform:AppName:Version by You 25 | # user_agent sounds scary, but it's just a string to identify what your using it for 26 | # It's common courtesy to use something like :: by 27 | # ex. "Window11:TestApp:v0.1 by u/Shifty-The-Dev" 28 | SUBREDDIT = askreddit -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from moviepy.editor import * 2 | import reddit, screenshot, time, subprocess, random, configparser, sys, math 3 | from os import listdir 4 | from os.path import isfile, join 5 | 6 | def createVideo(): 7 | config = configparser.ConfigParser() 8 | config.read('config.ini') 9 | outputDir = config["General"]["OutputDirectory"] 10 | 11 | startTime = time.time() 12 | 13 | # Get script from reddit 14 | # If a post id is listed, use that. Otherwise query top posts 15 | if (len(sys.argv) == 2): 16 | script = reddit.getContentFromId(outputDir, sys.argv[1]) 17 | else: 18 | postOptionCount = int(config["Reddit"]["NumberOfPostsToSelectFrom"]) 19 | script = reddit.getContent(outputDir, postOptionCount) 20 | fileName = script.getFileName() 21 | 22 | # Create screenshots 23 | screenshot.getPostScreenshots(fileName, script) 24 | 25 | # Setup background clip 26 | bgDir = config["General"]["BackgroundDirectory"] 27 | bgPrefix = config["General"]["BackgroundFilePrefix"] 28 | bgFiles = [f for f in listdir(bgDir) if isfile(join(bgDir, f))] 29 | bgCount = len(bgFiles) 30 | bgIndex = random.randint(0, bgCount-1) 31 | backgroundVideo = VideoFileClip( 32 | filename=f"{bgDir}/{bgPrefix}{bgIndex}.mp4", 33 | audio=False).subclip(0, script.getDuration()) 34 | w, h = backgroundVideo.size 35 | 36 | def __createClip(screenShotFile, audioClip, marginSize): 37 | imageClip = ImageClip( 38 | screenShotFile, 39 | duration=audioClip.duration 40 | ).set_position(("center", "center")) 41 | imageClip = imageClip.resize(width=(w-marginSize)) 42 | videoClip = imageClip.set_audio(audioClip) 43 | videoClip.fps = 1 44 | return videoClip 45 | 46 | # Create video clips 47 | print("Editing clips together...") 48 | clips = [] 49 | marginSize = int(config["Video"]["MarginSize"]) 50 | clips.append(__createClip(script.titleSCFile, script.titleAudioClip, marginSize)) 51 | for comment in script.frames: 52 | clips.append(__createClip(comment.screenShotFile, comment.audioClip, marginSize)) 53 | 54 | # Merge clips into single track 55 | contentOverlay = concatenate_videoclips(clips).set_position(("center", "center")) 56 | 57 | # Compose background/foreground 58 | final = CompositeVideoClip( 59 | clips=[backgroundVideo, contentOverlay], 60 | size=backgroundVideo.size).set_audio(contentOverlay.audio) 61 | final.duration = script.getDuration() 62 | final.set_fps(backgroundVideo.fps) 63 | 64 | # Write output to file 65 | print("Rendering final video...") 66 | bitrate = config["Video"]["Bitrate"] 67 | threads = config["Video"]["Threads"] 68 | outputFile = f"{outputDir}/{fileName}.mp4" 69 | final.write_videofile( 70 | outputFile, 71 | codec = 'mpeg4', 72 | threads = threads, 73 | bitrate = bitrate 74 | ) 75 | print(f"Video completed in {time.time() - startTime}") 76 | 77 | # Preview in VLC for approval before uploading 78 | if (config["General"].getboolean("PreviewBeforeUpload")): 79 | vlcPath = config["General"]["VLCPath"] 80 | p = subprocess.Popen([vlcPath, outputFile]) 81 | print("Waiting for video review. Type anything to continue") 82 | wait = input() 83 | 84 | print("Video is ready to upload!") 85 | print(f"Title: {script.title} File: {outputFile}") 86 | endTime = time.time() 87 | print(f"Total time: {endTime - startTime}") 88 | 89 | if __name__ == "__main__": 90 | createVideo() -------------------------------------------------------------------------------- /markdown_to_text.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from markdown import markdown 3 | import re 4 | 5 | def markdown_to_text(markdown_string): 6 | """ Converts a markdown string to plaintext """ 7 | 8 | # md -> html -> text since BeautifulSoup can extract text cleanly 9 | html = markdown(markdown_string) 10 | 11 | # remove code snippets 12 | html = re.sub(r'
(.*?)
', ' ', html) 13 | html = re.sub(r'(.*?)', ' ', html) 14 | html = re.sub(r'~~(.*?)~~', ' ', html) 15 | 16 | # extract text 17 | soup = BeautifulSoup(html, "html.parser") 18 | text = ''.join(soup.findAll(text=True)) 19 | 20 | return text -------------------------------------------------------------------------------- /reddit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import praw 4 | import markdown_to_text 5 | import time 6 | from videoscript import VideoScript 7 | import configparser 8 | 9 | config = configparser.ConfigParser() 10 | config.read('config.ini') 11 | CLIENT_ID = config["Reddit"]["CLIENT_ID"] 12 | CLIENT_SECRET = config["Reddit"]["CLIENT_SECRET"] 13 | USER_AGENT = config["Reddit"]["USER_AGENT"] 14 | SUBREDDIT = config["Reddit"]["SUBREDDIT"] 15 | 16 | def getContent(outputDir, postOptionCount) -> VideoScript: 17 | reddit = __getReddit() 18 | existingPostIds = __getExistingPostIds(outputDir) 19 | 20 | now = int(time.time()) 21 | autoSelect = postOptionCount == 0 22 | posts = [] 23 | 24 | for submission in reddit.subreddit(SUBREDDIT).top(time_filter="day", limit=postOptionCount*3): 25 | if (f"{submission.id}.mp4" in existingPostIds or submission.over_18): 26 | continue 27 | hoursAgoPosted = (now - submission.created_utc) / 3600 28 | print(f"[{len(posts)}] {submission.title} {submission.score} {'{:.1f}'.format(hoursAgoPosted)} hours ago") 29 | posts.append(submission) 30 | if (autoSelect or len(posts) >= postOptionCount): 31 | break 32 | 33 | if (autoSelect): 34 | return __getContentFromPost(posts[0]) 35 | else: 36 | postSelection = int(input("Input: ")) 37 | selectedPost = posts[postSelection] 38 | return __getContentFromPost(selectedPost) 39 | 40 | def getContentFromId(outputDir, submissionId) -> VideoScript: 41 | reddit = __getReddit() 42 | existingPostIds = __getExistingPostIds(outputDir) 43 | 44 | if (submissionId in existingPostIds): 45 | print("Video already exists!") 46 | exit() 47 | try: 48 | submission = reddit.submission(submissionId) 49 | except: 50 | print(f"Submission with id '{submissionId}' not found!") 51 | exit() 52 | return __getContentFromPost(submission) 53 | 54 | def __getReddit(): 55 | return praw.Reddit( 56 | client_id=CLIENT_ID, 57 | client_secret=CLIENT_SECRET, 58 | user_agent=USER_AGENT 59 | ) 60 | 61 | 62 | def __getContentFromPost(submission) -> VideoScript: 63 | content = VideoScript(submission.url, submission.title, submission.id) 64 | print(f"Creating video for post: {submission.title}") 65 | print(f"Url: {submission.url}") 66 | 67 | failedAttempts = 0 68 | for comment in submission.comments: 69 | if(content.addCommentScene(markdown_to_text.markdown_to_text(comment.body), comment.id)): 70 | failedAttempts += 1 71 | if (content.canQuickFinish() or (failedAttempts > 2 and content.canBeFinished())): 72 | break 73 | return content 74 | 75 | def __getExistingPostIds(outputDir): 76 | files = os.listdir(outputDir) 77 | # I'm sure anyone knowledgeable on python hates this. I had some weird 78 | # issues and frankly didn't care to troubleshoot. It works though... 79 | files = [f for f in files if os.path.isfile(outputDir+'/'+f)] 80 | return [re.sub(r'.*?-', '', file) for file in files] 81 | -------------------------------------------------------------------------------- /screenshot.py: -------------------------------------------------------------------------------- 1 | from selenium import webdriver 2 | from selenium.webdriver.common.by import By 3 | from selenium.webdriver.support.ui import WebDriverWait 4 | from selenium.webdriver.support import expected_conditions as EC 5 | 6 | # Config 7 | screenshotDir = "Screenshots" 8 | screenWidth = 400 9 | screenHeight = 800 10 | 11 | def getPostScreenshots(filePrefix, script): 12 | print("Taking screenshots...") 13 | driver, wait = __setupDriver(script.url) 14 | script.titleSCFile = __takeScreenshot(filePrefix, driver, wait) 15 | for commentFrame in script.frames: 16 | commentFrame.screenShotFile = __takeScreenshot(filePrefix, driver, wait, f"t1_{commentFrame.commentId}") 17 | driver.quit() 18 | 19 | def __takeScreenshot(filePrefix, driver, wait, handle="Post"): 20 | method = By.CLASS_NAME if (handle == "Post") else By.ID 21 | search = wait.until(EC.presence_of_element_located((method, handle))) 22 | driver.execute_script("window.focus();") 23 | 24 | fileName = f"{screenshotDir}/{filePrefix}-{handle}.png" 25 | fp = open(fileName, "wb") 26 | fp.write(search.screenshot_as_png) 27 | fp.close() 28 | return fileName 29 | 30 | def __setupDriver(url: str): 31 | options = webdriver.FirefoxOptions() 32 | options.headless = False 33 | options.enable_mobile = False 34 | driver = webdriver.Firefox(options=options) 35 | wait = WebDriverWait(driver, 10) 36 | 37 | driver.set_window_size(width=screenWidth, height=screenHeight) 38 | driver.get(url) 39 | 40 | return driver, wait -------------------------------------------------------------------------------- /videoscript.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from moviepy.editor import AudioFileClip 3 | import voiceover 4 | 5 | MAX_WORDS_PER_COMMENT = 100 6 | MIN_COMMENTS_FOR_FINISH = 4 7 | MIN_DURATION = 20 8 | MAX_DURATION = 58 9 | 10 | class VideoScript: 11 | title = "" 12 | fileName = "" 13 | titleSCFile = "" 14 | url = "" 15 | totalDuration = 0 16 | frames = [] 17 | 18 | def __init__(self, url, title, fileId) -> None: 19 | self.fileName = f"{datetime.today().strftime('%Y-%m-%d')}-{fileId}" 20 | self.url = url 21 | self.title = title 22 | self.titleAudioClip = self.__createVoiceOver("title", title) 23 | 24 | def canBeFinished(self) -> bool: 25 | return (len(self.frames) > 0) and (self.totalDuration > MIN_DURATION) 26 | 27 | def canQuickFinish(self) -> bool: 28 | return (len(self.frames) >= MIN_COMMENTS_FOR_FINISH) and (self.totalDuration > MIN_DURATION) 29 | 30 | def addCommentScene(self, text, commentId) -> None: 31 | wordCount = len(text.split()) 32 | if (wordCount > MAX_WORDS_PER_COMMENT): 33 | return True 34 | frame = ScreenshotScene(text, commentId) 35 | frame.audioClip = self.__createVoiceOver(commentId, text) 36 | if (frame.audioClip == None): 37 | return True 38 | self.frames.append(frame) 39 | 40 | def getDuration(self): 41 | return self.totalDuration 42 | 43 | def getFileName(self): 44 | return self.fileName 45 | 46 | def __createVoiceOver(self, name, text): 47 | file_path = voiceover.create_voice_over(f"{self.fileName}-{name}", text) 48 | audioClip = AudioFileClip(file_path) 49 | if (self.totalDuration + audioClip.duration > MAX_DURATION): 50 | return None 51 | self.totalDuration += audioClip.duration 52 | return audioClip 53 | 54 | 55 | class ScreenshotScene: 56 | text = "" 57 | screenShotFile = "" 58 | commentId = "" 59 | 60 | def __init__(self, text, commentId) -> None: 61 | self.text = text 62 | self.commentId = commentId -------------------------------------------------------------------------------- /voiceover.py: -------------------------------------------------------------------------------- 1 | import pyttsx3 2 | 3 | voiceoverDir = "Voiceovers" 4 | 5 | def create_voice_over(fileName, text): 6 | filePath = f"{voiceoverDir}/{fileName}.mp3" 7 | engine = pyttsx3.init() 8 | engine.save_to_file(text, filePath) 9 | engine.runAndWait() 10 | return filePath -------------------------------------------------------------------------------- /youtube.py: -------------------------------------------------------------------------------- 1 | # IMPORTANT: This currently does not work. However, this may give you 2 | # an idea for how to leverage YouTubeUploader to automate this step 3 | 4 | 5 | 6 | # from youtube_uploader_selenium import YouTubeUploader 7 | # import json 8 | 9 | # metaJson="metadata.json" 10 | # tags = ["reddit", "funny", "askreddit", "best", "montage", "compilation"] 11 | # description = """Reddit funny moments compilation from r/askreddit! 12 | # Be sure to subscribe for daily funny reddit posts. Check out my 13 | # channel for more hilarious reddit clips! 14 | # """ 15 | 16 | # def __createMetaJson(title): 17 | # dictionary = { 18 | # "title": f"{title} #shorts", 19 | # "description": description, 20 | # "tags": tags, 21 | # } 22 | # json_object = json.dumps(dictionary, indent=4) 23 | # with open(metaJson, "w") as outfile: 24 | # outfile.write(json_object) 25 | 26 | # def uploadVideo(filePath, title): 27 | # __createMetaJson(title) 28 | # uploader = YouTubeUploader(filePath, metaJson) 29 | # was_video_uploaded, video_id = uploader.upload() 30 | # assert was_video_uploaded --------------------------------------------------------------------------------