Merge branch 'develop' into MultiThreading

pull/690/head
Vaughn Bosu 2 years ago committed by GitHub
commit e14ec48569
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,31 +1,90 @@
REDDIT_CLIENT_ID=""
REDDIT_CLIENT_SECRET=""
REDDIT_USERNAME=""
REDDIT_PASSWORD=""
REDDIT_CLIENT_ID="" #fFAGRNJru1FTz70BzhT3Zg
#EXPLANATION the ID of your Reddit app of SCRIPT type
#RANGE 12:30
#MATCH_REGEX [-a-zA-Z0-9._~+/]+=*$
#OOB_ERROR The ID should be over 12 and under 30 characters, double check your input.
# If no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: "no"
REDDIT_CLIENT_SECRET="" #fFAGRNJru1FTz70BzhT3Zg
#EXPLANATION the SECRET of your Reddit app of SCRIPT type
#RANGE 20:40
#MATCH_REGEX [-a-zA-Z0-9._~+/]+=*$
#OOB_ERROR The secret should be over 20 and under 40 characters, double check your input.
REDDIT_USERNAME="" #asdfghjkl
#EXPLANATION the username of your reddit account
#RANGE 3:20
#MATCH_REGEX [_0-9a-zA-Z]+$
#OOB_ERROR A username HAS to be between 3 and 20 characters
REDDIT_PASSWORD="" #fFAGRNJru1FTz70BzhT3Zg
#EXPLANATION the password of your reddit account
#RANGE 8:None
#OOB_ERROR Password too short
#OPTIONAL
RANDOM_THREAD="no"
# If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: "no"
REDDIT_2FA="" #no
#MATCH_REGEX ^(yes|no)
#EXPLANATION Whether you have Reddit 2FA enabled, Valid options are "yes" and "no"
# Valid options are "yes" and "no" for the variable below
REDDIT_2FA=""
SUBREDDIT="AskReddit"
# True or False
#EXPLANATION what subreddit to pull posts from, the name of the sub, not the URL
#RANGE 3:20
#MATCH_REGEX [_0-9a-zA-Z]+$
#OOB_ERROR A subreddit name HAS to be between 3 and 20 characters
ALLOW_NSFW="False"
# Used if you want to use a specific post. example of one is urdtfx
#EXPLANATION Whether to allow NSFW content, True or False
#MATCH_REGEX ^(True|False)$
POST_ID=""
#set to either LIGHT or DARK
THEME="LIGHT"
# used if you want to run multiple times. set to an int e.g. 4 or 29 and leave blank for 1
TIMES_TO_RUN=""
# max number of characters a comment can have.
MAX_COMMENT_LENGTH="500" # default is 500
# Range is 0 -> 1 recommended around 0.8-0.9
OPACITY="1"
#MATCH_REGEX ^((?!://|://).)*$
#EXPLANATION Used if you want to use a specific post. example of one is urdtfx
THEME="LIGHT" #dark
#EXPLANATION sets the Reddit theme, either LIGHT or DARK
#MATCH_REGEX ^(dark|light|DARK|LIGHT)$
TIMES_TO_RUN="" #2
#EXPLANATION used if you want to run multiple times. set to an int e.g. 4 or 29 and leave blank for 1
MAX_COMMENT_LENGTH="500" #500
#EXPLANATION max number of characters a comment can have. default is 500
#RANGE 0:10000
#MATCH_TYPE int
#OOB_ERROR the max comment length should be between 0 and 10000
OPACITY="1" #.8
#EXPLANATION Sets the opacity of the comments when overlayed over the background
#RANGE 0:1
#MATCH_TYPE float
#OOB_ERROR The opacity HAS to be between 0 and 1
# If you want to translate the comments to another language, set the language code here.
# If empty, no translation will be done.
POSTLANG=""
#EXPLANATION Activates the translation feature, set the language code for translate or leave blank
# see different voice options: todo: add docs
VOICE="Matthew" # e.g. en_us_002
TTsChoice="polly"
#EXPLANATION sets the voice the TTS uses
# IN-PROGRESS - not yet implemented
TTSCHOICE="Polly"
#EXPLANATION the backend used for TTS. Without anything specified, the user will be prompted to choose one.
# IMPORTANT NOTE: if you use translate, you need to set this to googletranslate or tiktok and use custom voice in your language
STREAMLABS_VOICE="Joanna"
#EXPLANATION Sets the voice for the Streamlabs Polly TTS Engine. Check the file for more information on different voices.
AWS_VOICE="Joanna"
#EXPLANATION Sets the voice for the AWS Polly TTS Engine. Check the file for more information on different voices.
TIKTOK_VOICE="en_us_006"
#EXPLANATION Sets the voice for the TikTok TTS Engine. Check the file for more information on different voices.
#OPTIONAL
STORYMODE="False"
# IN-PROGRESS - not yet implemented

@ -1,10 +1,9 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
title: ""
labels: bug
assignees: ''
assignees: ""
---
**Describe the bug**
@ -20,9 +19,10 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**System (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- App version [e.g. 22]
- Python Version: [e.g. Python 3.6]
- OS: [e.g. Windows 11]
- App version / Branch [e.g. latest, V2.0, master, develop ]
**Additional context**
Add any other context about the problem here.

@ -0,0 +1,25 @@
# Description
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant context. List any dependencies that are required for this change. -->
# Issue Fixes
<!-- Fixes #(issue) if relevant-->
None
# Checklist:
- [ ] I am pushing changes to the **develop** branch
- [ ] I am using the recommended development environment
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have formatted and linted my code using python-black and pylint
- [ ] I have cleaned up unnecessary files
- [ ] My changes generate no new warnings
- [ ] My changes follow the existing code-style
- [ ] My changes are relevant to the project
# Any other information (e.g how to test the changes)
None

@ -5,7 +5,7 @@
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"

@ -1,3 +1,4 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
@ -12,61 +13,61 @@
name: "CodeQL"
on:
push:
branches: [ "master" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "master" ]
schedule:
- cron: '16 14 * * 3'
push:
branches: [ "master" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "master" ]
schedule:
- cron: '16 14 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

@ -149,7 +149,7 @@ disable=raw-checker-failed,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead
use-symbolic-message-instead,
attribute-defined-outside-init,
invalid-name,
missing-docstring,

@ -0,0 +1,127 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at the [discord server](https://discord.gg/yqNvvDMYpq).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

@ -34,7 +34,6 @@ The only original thing being done is the editing and gathering of all materials
- Python 3.6+
- Playwright (this should install automatically in installation)
- Sox
## Installation 👩‍💻
@ -43,18 +42,17 @@ The only original thing being done is the editing and gathering of all materials
2b **Manual Install**: Rename `.env.template` to `.env` and replace all values with the appropriate fields. To get Reddit keys (**required**), visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps) TL;DR set up an app that is a "script". Copy your keys into the `.env` file, along with whether your account uses two-factor authentication.
3. Install [SoX](https://sourceforge.net/projects/sox/files/sox/)
3. Run `pip install -r requirements.txt`
4. Run `pip install -r requirements.txt`
4. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command)
5. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command)
6. Run `python main.py` (unless you chose automatic install, then the installer will automatically run main.py)
5. Run `python main.py` (unless you chose automatic install, then the installer will automatically run main.py)
required\*\*), visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps) TL;DR set up an app that is a "script".
Copy your keys into the `.env` file, along with whether your account uses two-factor authentication.
7. Enjoy 😎
6. Enjoy 😎
(Note if you got an error installing or running the bot try first rerunning the command with a three after the name e.g. python3 or pip3)
## Video
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4

@ -1,13 +1,19 @@
#!/usr/bin/env python3
import random
import os
from gtts import gTTS
max_chars = 0
class GTTS:
def tts(
self,
req_text: str = "Google Text To Speech",
filename: str = "title.mp3",
random_speaker=False,
censor=False,
):
tts = gTTS(text=req_text, lang="en", slow=False)
tts.save(f"{filename}")
def __init__(self):
self.max_chars = 0
self.voices = []
def run(self, text, filepath):
tts = gTTS(text=text, lang=os.getenv("POSTLANG") or "en", slow=False)
tts.save(filepath)
def randomvoice(self):
return random.choice(self.voices)

@ -1,106 +0,0 @@
import os
import random
import re
import requests
import sox
from moviepy.audio.AudioClip import concatenate_audioclips, CompositeAudioClip
from moviepy.audio.io.AudioFileClip import AudioFileClip
from requests.exceptions import JSONDecodeError
voices = [
"Brian",
"Emma",
"Russell",
"Joey",
"Matthew",
"Joanna",
"Kimberly",
"Amy",
"Geraint",
"Nicole",
"Justin",
"Ivy",
"Kendra",
"Salli",
"Raveena",
]
# valid voices https://lazypy.ro/tts/
class POLLY:
def __init__(self):
self.url = "https://streamlabs.com/polly/speak"
def tts(
self,
req_text: str = "Amazon Text To Speech",
filename: str = "title.mp3",
random_speaker=False,
censor=False,
):
if random_speaker:
voice = self.randomvoice()
else:
if not os.getenv("VOICE"):
return ValueError(
"Please set the environment variable VOICE to a valid voice. options are: {}".format(
voices
)
)
voice = str(os.getenv("VOICE")).capitalize()
body = {"voice": voice, "text": req_text, "service": "polly"}
response = requests.post(self.url, data=body)
try:
voice_data = requests.get(response.json()["speak_url"])
with open(filename, "wb") as f:
f.write(voice_data.content)
except (KeyError, JSONDecodeError):
if response.json()["error"] == "Text length is too long!":
chunks = [m.group().strip() for m in re.finditer(r" *((.{0,499})(\.|.$))", req_text)]
audio_clips = []
cbn = sox.Combiner()
chunkId = 0
for chunk in chunks:
body = {"voice": voice, "text": chunk, "service": "polly"}
resp = requests.post(self.url, data=body)
voice_data = requests.get(resp.json()["speak_url"])
with open(filename.replace(".mp3", f"-{chunkId}.mp3"), "wb") as out:
out.write(voice_data.content)
audio_clips.append(filename.replace(".mp3", f"-{chunkId}.mp3"))
chunkId = chunkId + 1
try:
if len(audio_clips) > 1:
cbn.convert(samplerate=44100, n_channels=2)
cbn.build(audio_clips, filename, "concatenate")
else:
os.rename(audio_clips[0], filename)
except (
sox.core.SoxError,
FileNotFoundError,
): # https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/67#issuecomment-1150466339
for clip in audio_clips:
i = audio_clips.index(clip) # get the index of the clip
audio_clips = (
audio_clips[:i] + [AudioFileClip(clip)] + audio_clips[i + 1 :]
) # replace the clip with an AudioFileClip
audio_concat = concatenate_audioclips(audio_clips)
audio_composite = CompositeAudioClip([audio_concat])
audio_composite.write_audiofile(filename, 44100, 2, 2000, None)
def make_readable(self, text):
"""
Amazon Polly fails to read some symbols properly such as '& (and)'.
So we normalize input text before passing it to the service
"""
text = text.replace("&", "and")
return text
def randomvoice(self):
return random.choice(voices)

@ -1,12 +1,7 @@
import base64
import os
import random
import re
import requests
import sox
from moviepy.audio.AudioClip import concatenate_audioclips, CompositeAudioClip
from moviepy.audio.io.AudioFileClip import AudioFileClip
from requests.adapters import HTTPAdapter, Retry
# from profanity_filter import ProfanityFilter
@ -67,75 +62,39 @@ noneng = [
class TikTok: # TikTok Text-to-Speech Wrapper
def __init__(self):
self.URI_BASE = (
"https://api16-normal-useast5.us.tiktokv.com/media/api/text/speech/invoke/?text_speaker="
)
def tts(
self,
req_text: str = "TikTok Text To Speech",
filename: str = "title.mp3",
random_speaker: bool = False,
censor=False,
):
req_text = req_text.replace("+", "plus").replace(" ", "+").replace("&", "and")
if censor:
# req_text = pf.censor(req_text)
pass
self.URI_BASE = "https://api16-normal-useast5.us.tiktokv.com/media/api/text/speech/invoke/?text_speaker="
self.max_chars = 300
self.voices = {"human": human, "nonhuman": nonhuman, "noneng": noneng}
def run(self, text, filepath, random_voice: bool = False):
# if censor:
# req_text = pf.censor(req_text)
# pass
voice = (
self.randomvoice() if random_speaker else (os.getenv("VOICE") or random.choice(human))
self.randomvoice()
if random_voice
else (os.getenv("TIKTOK_VOICE") or random.choice(self.voices["human"]))
)
chunks = [m.group().strip() for m in re.finditer(r" *((.{0,299})(\.|.$))", req_text)]
audio_clips = []
cbn = sox.Combiner()
# cbn.set_input_format(file_type=["mp3" for _ in chunks])
chunkId = 0
for chunk in chunks:
try:
r = requests.post(f"{self.URI_BASE}{voice}&req_text={chunk}&speaker_map_type=0")
except requests.exceptions.SSLError:
# https://stackoverflow.com/a/47475019/18516611
session = requests.Session()
retry = Retry(connect=3, backoff_factor=0.5)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
r = session.post(f"{self.URI_BASE}{voice}&req_text={chunk}&speaker_map_type=0")
print(r.text)
vstr = [r.json()["data"]["v_str"]][0]
b64d = base64.b64decode(vstr)
with open(filename.replace(".mp3", f"-{chunkId}.mp3"), "wb") as out:
out.write(b64d)
audio_clips.append(filename.replace(".mp3", f"-{chunkId}.mp3"))
chunkId = chunkId + 1
try:
if len(audio_clips) > 1:
cbn.convert(samplerate=44100, n_channels=2)
cbn.build(audio_clips, filename, "concatenate")
else:
os.rename(audio_clips[0], filename)
except (
sox.core.SoxError,
FileNotFoundError,
): # https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/67#issuecomment-1150466339
for clip in audio_clips:
i = audio_clips.index(clip) # get the index of the clip
audio_clips = (
audio_clips[:i] + [AudioFileClip(clip)] + audio_clips[i + 1 :]
) # replace the clip with an AudioFileClip
audio_concat = concatenate_audioclips(audio_clips)
audio_composite = CompositeAudioClip([audio_concat])
audio_composite.write_audiofile(filename, 44100, 2, 2000, None)
@staticmethod
def randomvoice():
ok_or_good = random.randrange(1, 10)
if ok_or_good == 1: # 1/10 chance of ok voice
return random.choice(voices)
return random.choice(human) # 9/10 chance of good voice
r = requests.post(
f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0"
)
except requests.exceptions.SSLError:
# https://stackoverflow.com/a/47475019/18516611
session = requests.Session()
retry = Retry(connect=3, backoff_factor=0.5)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
r = session.post(
f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0"
)
# print(r.text)
vstr = [r.json()["data"]["v_str"]][0]
b64d = base64.b64decode(vstr)
with open(filepath, "wb") as out:
out.write(b64d)
def randomvoice(self):
return random.choice(self.voices["human"])

@ -0,0 +1,66 @@
#!/usr/bin/env python3
from boto3 import Session
from botocore.exceptions import BotoCoreError, ClientError
import sys
import os
import random
voices = [
"Brian",
"Emma",
"Russell",
"Joey",
"Matthew",
"Joanna",
"Kimberly",
"Amy",
"Geraint",
"Nicole",
"Justin",
"Ivy",
"Kendra",
"Salli",
"Raveena",
]
class AWSPolly:
def __init__(self):
self.max_chars = 0
self.voices = voices
def run(self, text, filepath, random_voice: bool = False):
session = Session(profile_name="polly")
polly = session.client("polly")
if random_voice:
voice = self.randomvoice()
else:
if not os.getenv("VOICE"):
return ValueError(
f"Please set the environment variable VOICE to a valid voice. options are: {voices}"
)
voice = str(os.getenv("AWS_VOICE")).capitalize()
try:
# Request speech synthesis
response = polly.synthesize_speech(
Text=text, OutputFormat="mp3", VoiceId=voice, Engine="neural"
)
except (BotoCoreError, ClientError) as error:
# The service returned an error, exit gracefully
print(error)
sys.exit(-1)
# Access the audio stream from the response
if "AudioStream" in response:
file = open(filepath, "wb")
file.write(response["AudioStream"].read())
file.close()
# print_substep(f"Saved Text {idx} to MP3 files successfully.", style="bold green")
else:
# The response didn't contain audio data, exit gracefully
print("Could not stream audio")
sys.exit(-1)
def randomvoice(self):
return random.choice(self.voices)

@ -0,0 +1,110 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import Tuple
import re
from os import getenv
from mutagen.mp3 import MP3
import translators as ts
from rich.progress import track
from moviepy.editor import AudioFileClip, CompositeAudioClip, concatenate_audioclips
from utils.console import print_step, print_substep
from utils.voice import sanitize_text
class TTSEngine:
"""Calls the given TTS engine to reduce code duplication and allow multiple TTS engines.
Args:
tts_module : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method.
reddit_object : The reddit object that contains the posts to read.
path (Optional) : The unix style path to save the mp3 files to. This must not have leading or trailing slashes.
max_length (Optional) : The maximum length of the mp3 files in total.
Notes:
tts_module must take the arguments text and filepath.
"""
def __init__(
self,
tts_module,
reddit_object: dict,
path: str = "assets/temp/mp3",
max_length: int = 50,
):
self.tts_module = tts_module()
self.reddit_object = reddit_object
self.path = path
self.max_length = max_length
self.length = 0
def run(self) -> Tuple[int, int]:
Path(self.path).mkdir(parents=True, exist_ok=True)
# This file needs to be removed in case this post does not use post text, so that it wont appear in the final video
try:
Path(f"{self.path}/posttext.mp3").unlink()
except OSError:
pass
print_step("Saving Text to MP3 files...")
self.call_tts("title", self.reddit_object["thread_title"])
if (
self.reddit_object["thread_post"] != ""
and getenv("STORYMODE", "").casefold() == "true"
):
self.call_tts("posttext", self.reddit_object["thread_post"])
idx = None
for idx, comment in track(
enumerate(self.reddit_object["comments"]), "Saving..."
):
# ! Stop creating mp3 files if the length is greater than max length.
if self.length > self.max_length:
break
if not self.tts_module.max_chars:
self.call_tts(f"{idx}", comment["comment_body"])
else:
self.split_post(comment["comment_body"], idx)
print_substep("Saved Text to MP3 files successfully.", style="bold green")
return self.length, idx
def split_post(self, text: str, idx: int) -> str:
split_files = []
split_text = [
x.group().strip()
for x in re.finditer(
rf" *((.{{0,{self.tts_module.max_chars}}})(\.|.$))", text
)
]
idy = None
for idy, text_cut in enumerate(split_text):
# print(f"{idx}-{idy}: {text_cut}\n")
self.call_tts(f"{idx}-{idy}.part", text_cut)
split_files.append(AudioFileClip(f"{self.path}/{idx}-{idy}.part.mp3"))
CompositeAudioClip([concatenate_audioclips(split_files)]).write_audiofile(
f"{self.path}/{idx}.mp3", fps=44100, verbose=False, logger=None
)
for i in range(0, idy + 1):
# print(f"Cleaning up {self.path}/{idx}-{i}.part.mp3")
Path(f"{self.path}/{idx}-{i}.part.mp3").unlink()
def call_tts(self, filename: str, text: str):
self.tts_module.run(
text=process_text(text), filepath=f"{self.path}/{filename}.mp3"
)
self.length += MP3(f"{self.path}/{filename}.mp3").info.length
def process_text(text: str):
lang = getenv("POSTLANG", "")
new_text = sanitize_text(text)
if lang:
print_substep("Translating Text...")
new_text = ts.google(text, to_language=lang)
return new_text

@ -0,0 +1,53 @@
import random
import os
import requests
from requests.exceptions import JSONDecodeError
voices = [
"Brian",
"Emma",
"Russell",
"Joey",
"Matthew",
"Joanna",
"Kimberly",
"Amy",
"Geraint",
"Nicole",
"Justin",
"Ivy",
"Kendra",
"Salli",
"Raveena",
]
# valid voices https://lazypy.ro/tts/
class StreamlabsPolly:
def __init__(self):
self.url = "https://streamlabs.com/polly/speak"
self.max_chars = 550
self.voices = voices
def run(self, text, filepath, random_voice: bool = False):
if random_voice:
voice = self.randomvoice()
else:
if not os.getenv("VOICE"):
return ValueError(
f"Please set the environment variable VOICE to a valid voice. options are: {voices}"
)
voice = str(os.getenv("STREAMLABS_VOICE")).capitalize()
body = {"voice": voice, "text": text, "service": "polly"}
response = requests.post(self.url, data=body)
try:
voice_data = requests.get(response.json()["speak_url"])
with open(filepath, "wb") as f:
f.write(voice_data.content)
except (KeyError, JSONDecodeError):
print("Error occured calling Streamlabs Polly")
def randomvoice(self):
return random.choice(self.voices)

@ -1,24 +0,0 @@
from os import getenv
from dotenv import load_dotenv
from TTS.GTTS import GTTS
from TTS.POLLY import POLLY
from TTS.TikTok import TikTok
from utils.console import print_substep
CHOICE_DIR = {"tiktok": TikTok, "gtts": GTTS, "polly": POLLY}
class TTS:
def __new__(cls):
load_dotenv()
try:
CHOICE = getenv("TTsChoice").casefold()
except AttributeError:
print_substep("None defined. Defaulting to 'polly.'")
CHOICE = "polly"
valid_keys = [key.lower() for key in CHOICE_DIR.keys()]
if CHOICE not in valid_keys:
raise ValueError(f"{CHOICE} is not valid. Please use one of these {valid_keys} options")
return CHOICE_DIR.get(CHOICE)()

@ -1,4 +1,4 @@
import time
#!/usr/bin/env python
from subprocess import Popen
from dotenv import load_dotenv
@ -6,11 +6,14 @@ from os import getenv, name
from reddit.subreddit import get_subreddit_threads
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step
# from utils.checker import envUpdate
from video_creation.background import download_background, chop_background_video
from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import download_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3
from utils.checker import check_env
VERSION = 2.1
print(
"""
@ -22,30 +25,21 @@ print(
"""
)
load_dotenv()
# Modified by JasonLovesDoggo
print_markdown(
"### Thanks for using this tool! [Feel free to contribute to this project on GitHub!](https://lewismenelaws.com) If you have any questions, feel free to reach out to me on Twitter or submit a GitHub issue. You can find solutions to many common problems in the [Documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/)"
)
time.sleep(1)
client_id = getenv("REDDIT_CLIENT_ID")
client_secret = getenv("REDDIT_CLIENT_SECRET")
username = getenv("REDDIT_USERNAME")
password = getenv("REDDIT_PASSWORD")
reddit2fa = getenv("REDDIT_2FA")
def main():
#envUpdate()
if check_env() is not True:
exit()
load_dotenv()
cleanup()
def get_obj():
reddit_obj = get_subreddit_threads()
return reddit_obj
reddit_object = get_obj()
reddit_object = get_subreddit_threads()
length, number_of_comments = save_text_to_mp3(reddit_object)
download_screenshots_of_reddit_posts(reddit_object, number_of_comments)
download_background()
@ -54,8 +48,7 @@ def main():
def run_many(times):
for x in range(times):
x = x + 1
for x in range(1, times + 1):
print_step(
f'on the {x}{("st" if x == 1 else ("nd" if x == 2 else ("rd" if x == 3 else "th")))} iteration of {times}'
) # correct 1st 2nd 3rd 4th 5th....

@ -31,7 +31,9 @@ def get_subreddit_threads():
content = {}
if str(getenv("REDDIT_2FA")).casefold() == "yes":
print("\nEnter your two-factor authentication code from your authenticator app.\n")
print(
"\nEnter your two-factor authentication code from your authenticator app.\n"
)
code = input("> ")
print()
pw = getenv("REDDIT_PASSWORD")
@ -55,14 +57,18 @@ def get_subreddit_threads():
): # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython")
try:
subreddit = reddit.subreddit(
re.sub(r"r\/", "", input("What subreddit would you like to pull from? "))
re.sub(
r"r\/", "", input("What subreddit would you like to pull from? ")
)
# removes the r/ from the input
)
except ValueError:
subreddit = reddit.subreddit("askreddit")
print_substep("Subreddit not defined. Using AskReddit.")
else:
print_substep(f"Using subreddit: r/{getenv('SUBREDDIT')} from environment variable config")
print_substep(
f"Using subreddit: r/{getenv('SUBREDDIT')} from environment variable config"
)
subreddit = reddit.subreddit(
getenv("SUBREDDIT")
) # Allows you to specify in .env. Done for automation purposes.
@ -83,12 +89,14 @@ def get_subreddit_threads():
print_substep(f"Thread has {upvotes} upvotes", style="bold blue")
print_substep(f"Thread has a upvote ratio of {ratio}%", style="bold blue")
print_substep(f"Thread has {num_comments} comments", style="bold blue")
environ["VIDEO_TITLE"] = str(textify(submission.title)) # todo use global instend of env vars
environ["VIDEO_TITLE"] = str(
textify(submission.title)
) # todo use global instend of env vars
environ["VIDEO_ID"] = str(textify(submission.id))
content["thread_url"] = f"https://reddit.com{submission.permalink}"
content["thread_title"] = submission.title
# content["thread_content"] = submission.content
content["thread_post"] = submission.selftext
content["comments"] = []
for top_level_comment in submission.comments:
if isinstance(top_level_comment, MoreComments):

@ -1,3 +1,5 @@
boto3==1.24.12
botocore==1.27.12
gTTS==2.2.4
moviepy==1.0.3
mutagen==1.45.1
@ -7,4 +9,4 @@ python-dotenv==0.20.0
pytube==12.1.0
requests==2.28.0
rich==12.4.4
sox==1.4.1
translators==5.2.2

@ -10,51 +10,14 @@ from utils.console import print_markdown
from utils.console import print_step
from rich.console import Console
from utils.loader import Loader
from utils.console import handle_input
console = Console()
def handle_input(
message: str = "",
check_type=False,
match: str = "",
err_message: str = "",
nmin=None,
nmax=None,
oob_error="",
):
match = re.compile(match + "$")
while True:
user_input = input(message + "\n> ").strip()
if re.match(match, user_input) is not None:
if check_type is not False:
try:
user_input = check_type(user_input)
if nmin is not None and user_input < nmin:
console.log("[red]" + oob_error) # Input too low failstate
continue
if nmax is not None and user_input > nmax:
console.log("[red]" + oob_error) # Input too high
continue
break # Successful type conversion and number in bounds
except ValueError:
console.log("[red]" + err_message) # Type conversion failed
continue
if nmin is not None and len(user_input) < nmin: # Check if string is long enough
console.log("[red]" + oob_error)
continue
if nmax is not None and len(user_input) > nmax: # Check if string is not too long
console.log("[red]" + oob_error)
continue
break
console.log("[red]" + err_message)
return user_input
if os.path.isfile(".setup-done-before"):
console.log(
"[red]Setup was already completed! Please make sure you have to run this script again. If that is such, delete the file .setup-done-before"
console.print(
"[red]WARNING: Setup was already completed! Please make sure you have to run this script again. If that is such, delete the file .setup-done-before"
)
exit()
@ -89,15 +52,15 @@ if input("Are you sure you want to continue? > ").strip().casefold() != "yes":
console.print("[bold green]Alright! Let's get started!")
print()
console.log("Ensure you have the following ready to enter:")
console.log("[bold green]Reddit Client ID")
console.log("[bold green]Reddit Client Secret")
console.log("[bold green]Reddit Username")
console.log("[bold green]Reddit Password")
console.log("[bold green]Reddit 2FA (yes or no)")
console.log("[bold green]Opacity (range of 0-1, decimals are OK)")
console.log("[bold green]Subreddit (without r/ or /r/)")
console.log("[bold green]Theme (light or dark)")
console.print("Ensure you have the following ready to enter:")
console.print("[bold green]Reddit Client ID")
console.print("[bold green]Reddit Client Secret")
console.print("[bold green]Reddit Username")
console.print("[bold green]Reddit Password")
console.print("[bold green]Reddit 2FA (yes or no)")
console.print("[bold green]Opacity (range of 0-1, decimals are OK)")
console.print("[bold green]Subreddit (without r/ or /r/)")
console.print("[bold green]Theme (light or dark)")
console.print(
"[green]If you don't have these, please follow the instructions in the README.md file to set them up."
)
@ -117,7 +80,7 @@ console.print("[bold green]Alright! Let's get started!")
# Begin the setup process.
console.log("Enter your credentials now.")
console.print("Enter your credentials now.")
client_id = handle_input(
"Client ID > ",
False,
@ -178,7 +141,7 @@ theme = handle_input(
)
loader = Loader("Attempting to save your credentials...", "Done!").start()
# you can also put a while loop here, e.g. while VideoIsBeingMade == True: ...
console.log("Writing to the .env file...")
console.print("Writing to the .env file...")
with open(".env", "w") as f:
f.write(
f"""REDDIT_CLIENT_ID="{client_id}"
@ -199,7 +162,7 @@ with open(".setup-done-before", "w") as f:
loader.stop()
console.log("[bold green]Setup Complete! Returning...")
console.print("[bold green]Setup Complete! Returning...")
# Post-Setup: send message and try to run main.py again.
subprocess.call("python3 main.py", shell=True)

@ -0,0 +1,195 @@
#!/usr/bin/env python
import os
from rich.console import Console
from rich.table import Table
from rich import box
import re
import dotenv
from utils.console import handle_input
console = Console()
def check_env() -> bool:
"""Checks to see what's been put in .env
Returns:
bool: Whether or not everything was put in properly
"""
if not os.path.exists(".env.template"):
console.print("[red]Couldn't find .env.template. Unable to check variables.")
return True
if not os.path.exists(".env"):
console.print("[red]Couldn't find the .env file, creating one now.")
with open(".env", "x") as file:
file.write("")
success = True
with open(".env.template", "r") as template:
# req_envs = [env.split("=")[0] for env in template.readlines() if "=" in env]
matching = {}
explanations = {}
bounds = {}
types = {}
oob_errors = {}
examples = {}
req_envs = []
var_optional = False
for line in template.readlines():
if line.startswith("#") is not True and "=" in line and var_optional is not True:
req_envs.append(line.split("=")[0])
if "#" in line:
examples[line.split("=")[0]] = "#".join(line.split("#")[1:]).strip()
elif "#OPTIONAL" in line:
var_optional = True
elif line.startswith("#MATCH_REGEX "):
matching[req_envs[-1]] = line.removeprefix("#MATCH_REGEX ")[:-1]
var_optional = False
elif line.startswith("#OOB_ERROR "):
oob_errors[req_envs[-1]] = line.removeprefix("#OOB_ERROR ")[:-1]
var_optional = False
elif line.startswith("#RANGE "):
bounds[req_envs[-1]] = tuple(
map(
lambda x: float(x) if x != "None" else None,
line.removeprefix("#RANGE ")[:-1].split(":"),
)
)
var_optional = False
elif line.startswith("#MATCH_TYPE "):
types[req_envs[-1]] = eval(line.removeprefix("#MATCH_TYPE ")[:-1].split()[0])
var_optional = False
elif line.startswith("#EXPLANATION "):
explanations[req_envs[-1]] = line.removeprefix("#EXPLANATION ")[:-1]
var_optional = False
else:
var_optional = False
missing = set()
incorrect = set()
dotenv.load_dotenv()
for env in req_envs:
value = os.getenv(env)
if value is None:
missing.add(env)
continue
if env in matching.keys():
re.match(matching[env], value) is None and incorrect.add(env)
if env in bounds.keys() and env not in types.keys():
len(value) >= bounds[env][0] or (
len(bounds[env]) > 1 and bounds[env][1] >= len(value)
) or incorrect.add(env)
continue
if env in types.keys():
try:
temp = types[env](value)
if env in bounds.keys():
(bounds[env][0] <= temp or incorrect.add(env)) and len(bounds[env]) > 1 and (
bounds[env][1] >= temp or incorrect.add(env)
)
except ValueError:
incorrect.add(env)
if len(missing):
table = Table(
title="Missing variables",
highlight=True,
show_lines=True,
box=box.ROUNDED,
border_style="#414868",
header_style="#C0CAF5 bold",
title_justify="left",
title_style="#C0CAF5 bold",
)
table.add_column("Variable", justify="left", style="#7AA2F7 bold", no_wrap=True)
table.add_column("Explanation", justify="left", style="#BB9AF7", no_wrap=False)
table.add_column("Example", justify="center", style="#F7768E", no_wrap=True)
table.add_column("Min", justify="right", style="#F7768E", no_wrap=True)
table.add_column("Max", justify="left", style="#F7768E", no_wrap=True)
for env in missing:
table.add_row(
env,
explanations[env] if env in explanations.keys() else "No explanation given",
examples[env] if env in examples.keys() else "",
str(bounds[env][0]) if env in bounds.keys() and bounds[env][1] is not None else "",
str(bounds[env][1])
if env in bounds.keys() and len(bounds[env]) > 1 and bounds[env][1] is not None
else "",
)
console.print(table)
success = False
if len(incorrect):
table = Table(
title="Incorrect variables",
highlight=True,
show_lines=True,
box=box.ROUNDED,
border_style="#414868",
header_style="#C0CAF5 bold",
title_justify="left",
title_style="#C0CAF5 bold",
)
table.add_column("Variable", justify="left", style="#7AA2F7 bold", no_wrap=True)
table.add_column("Current value", justify="left", style="#F7768E", no_wrap=False)
table.add_column("Explanation", justify="left", style="#BB9AF7", no_wrap=False)
table.add_column("Example", justify="center", style="#F7768E", no_wrap=True)
table.add_column("Min", justify="right", style="#F7768E", no_wrap=True)
table.add_column("Max", justify="left", style="#F7768E", no_wrap=True)
for env in incorrect:
table.add_row(
env,
os.getenv(env),
explanations[env] if env in explanations.keys() else "No explanation given",
str(types[env].__name__) if env in types.keys() else "str",
str(bounds[env][0]) if env in bounds.keys() else "None",
str(bounds[env][1]) if env in bounds.keys() and len(bounds[env]) > 1 else "None",
)
missing.add(env)
console.print(table)
success = False
if success is True:
return True
console.print(
"[green]Do you want to automatically overwrite incorrect variables and add the missing variables? (y/n)"
)
if not input().casefold().startswith("y"):
console.print("[red]Aborting: Unresolved missing variables")
return False
if len(incorrect):
with open(".env", "r+") as env_file:
lines = []
for line in env_file.readlines():
line.split("=")[0].strip() not in incorrect and lines.append(line)
env_file.seek(0)
env_file.write("\n".join(lines))
env_file.truncate()
console.print("[green]Successfully removed incorrectly set variables from .env")
with open(".env", "a") as env_file:
for env in missing:
env_file.write(
env
+ "="
+ ('"' if env not in types.keys() else "")
+ str(
handle_input(
"[#F7768E bold]" + env + "[#C0CAF5 bold]=",
types[env] if env in types.keys() else False,
matching[env] if env in matching.keys() else ".*",
explanations[env]
if env in explanations.keys()
else "Incorrect input. Try again.",
bounds[env][0] if env in bounds.keys() else None,
bounds[env][1] if env in bounds.keys() and len(bounds[env]) > 1 else None,
oob_errors[env] if env in oob_errors.keys() else "Input too long/short.",
extra_info="[#C0CAF5 bold]⮶ "
+ (
explanations[env] if env in explanations.keys() else "No info available"
),
)
)
+ ('"' if env not in types.keys() else "")
+ "\n"
)
return True
if __name__ == "__main__":
check_env()

@ -3,6 +3,11 @@ from os.path import exists
def cleanup() -> int:
"""Deletes all temporary assets in assets/temp
Returns:
int: How many files were deleted
"""
if exists("./assets/temp"):
count = 0
files = [f for f in os.listdir(".") if f.endswith(".mp4") and "temp" in f.lower()]

@ -4,6 +4,8 @@ from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
from rich.columns import Columns
import re
console = Console()
@ -25,3 +27,50 @@ def print_step(text):
def print_substep(text, style=""):
"""Prints a rich info message without the panelling."""
console.print(text, style=style)
def print_table(items):
"""Prints items in a table."""
console.print(Columns([Panel(f"[yellow]{item}", expand=True) for item in items]))
def handle_input(
message: str = "",
check_type=False,
match: str = "",
err_message: str = "",
nmin=None,
nmax=None,
oob_error="",
extra_info="",
):
match = re.compile(match + "$")
console.print(extra_info, no_wrap=True)
while True:
console.print(message, end="")
user_input = input("").strip()
if re.match(match, user_input) is not None:
if check_type is not False:
try:
user_input = check_type(user_input)
if nmin is not None and user_input < nmin:
console.print("[red]" + oob_error) # Input too low failstate
continue
if nmax is not None and user_input > nmax:
console.print("[red]" + oob_error) # Input too high
continue
break # Successful type conversion and number in bounds
except ValueError:
console.print("[red]" + err_message) # Type conversion failed
continue
if nmin is not None and len(user_input) < nmin: # Check if string is long enough
console.print("[red]" + oob_error)
continue
if nmax is not None and len(user_input) > nmax: # Check if string is not too long
console.print("[red]" + oob_error)
continue
break
console.print("[red]" + err_message)
return user_input

@ -1,9 +0,0 @@
$envFile = Get-Content ".\.env.template"
$envFile -split "=" | Where-Object {$_ -notmatch '\"'} | Set-Content ".\envVarsbefSpl.txt"
Get-Content ".\envVarsbefSpl.txt" | Where-Object {$_ -notmatch '\#'} | Set-Content ".\envVarsN.txt"
Get-Content ".\envVarsN.txt" | Where-Object {$_ -ne ''} | Set-Content ".\video_creation\data\envvars.txt"
Remove-Item ".\envVarsbefSpl.txt"
Remove-Item ".\envVarsN.txt"
Write-Host $nowSplit

@ -1,9 +0,0 @@
$envFile = Get-Content ".\.env"
$envFile -split "=" | Where-Object {$_ -notmatch '\"'} | Set-Content ".\envVarsbefSpl.txt"
Get-Content ".\envVarsbefSpl.txt" | Where-Object {$_ -notmatch '\#'} | Set-Content ".\envVarsN.txt"
Get-Content ".\envVarsN.txt" | Where-Object {$_ -ne ''} | Set-Content ".\video_creation\data\envvars.txt"
Remove-Item ".\envVarsbefSpl.txt"
Remove-Item ".\envVarsN.txt"
Write-Host $nowSplit

@ -4,7 +4,16 @@ from os import getenv
from utils.console import print_substep
def get_subreddit_undone(submissions: List, subreddit):
def get_subreddit_undone(submissions: list, subreddit):
"""_summary_
Args:
submissions (list): List of posts that are going to potentially be generated into a video
subreddit (praw.Reddit.SubredditHelper): Chosen subreddit
Returns:
Any: The submission that has not been done
"""
"""
recursively checks if the top submission in the list was already done.
"""
@ -27,7 +36,16 @@ def get_subreddit_undone(submissions: List, subreddit):
) # all of the videos in hot have already been done
def already_done(done_videos: list, submission):
def already_done(done_videos: list, submission)->bool:
"""Checks to see if the given submission is in the list of videos
Args:
done_videos (list): Finished videos
submission (Any): The submission
Returns:
Boolean: Whether the video was found in the list
"""
for video in done_videos:
if video["id"] == str(submission):

@ -5,10 +5,17 @@ from utils.console import print_step
def check_done(
redditobj,
): # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack
"""params:
reddit_object: The Reddit Object you received in askreddit.py"""
redditobj:dict[str],
)->dict[str]|None: # 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 (dict[str]): Reddit object gotten from reddit/subreddit.py
Returns:
dict[str]|None: Reddit object in args
"""
with open("./video_creation/data/videos.json", "r") as done_vids_raw:
done_videos = json.load(done_vids_raw)
for video in done_videos:

@ -1,12 +1,17 @@
import re
def sanitize_text(text):
"""
Sanitizes the text for tts.
What gets removed:
- following characters`^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+`
- any http or https links
def sanitize_text(text: str) -> str:
"""Sanitizes the text for tts.
What gets removed:
- following characters`^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+`
- any http or https links
Args:
text (str): Text to be sanitized
Returns:
str: Sanitized text
"""
# remove any urls from the text
@ -17,6 +22,6 @@ def sanitize_text(text):
# note: not removing apostrophes
regex_expr = r"\s['|]|['|]\s|[\^_~@!&;#:\-%“”‘\"%\*/{}\[\]\(\)\\|<>=+]"
result = re.sub(regex_expr, " ", result)
result = result.replace("+", "plus").replace("&", "and")
# remove extra whitespace
return " ".join(result.split())

@ -2,13 +2,24 @@ import random
from os import listdir, environ
from pathlib import Path
from random import randrange
from pytube import YouTube
from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip
from moviepy.editor import VideoFileClip
from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip
from pytube import YouTube
from utils.console import print_step, print_substep
def get_start_and_end_times(video_length, length_of_clip):
def get_start_and_end_times(video_length:int, length_of_clip:int)->tuple[int,int]:
"""Generates a random interval of time to be used as the beckground of the video.
Args:
video_length (int): Length of the video
length_of_clip (int): Length of the video to be used as the background
Returns:
tuple[int,int]: Start and end time of the randomized interval
"""
random_time = randrange(180, int(length_of_clip) - int(video_length))
return random_time, random_time + video_length
@ -26,7 +37,7 @@ def download_background():
]
# note: make sure the file name doesn't include an - in it
if not len(listdir("./assets/backgrounds")) >= len(
background_options
background_options
): # if there are any background videos not installed
print_step(
"We need to download the backgrounds videos. they are fairly large but it's only done once. 😎"
@ -40,10 +51,17 @@ def download_background():
"assets/backgrounds", filename=f"{credit}-{filename}"
)
print_substep("Background videos downloaded successfully! 🎉", style="bold green")
print_substep(
"Background videos downloaded successfully! 🎉", style="bold green"
)
def chop_background_video(video_length):
def chop_background_video(video_length:int):
"""Generates the background footage to be used in the video and writes it to assets/temp/background.mp4
Args:
video_length (int): Length of the clip where the background footage is to be taken out of
"""
print_step("Finding a spot in the backgrounds video to chop...✂️")
choice = random.choice(listdir("assets/backgrounds"))
environ["background_credit"] = choice.split("-")[0]
@ -51,11 +69,16 @@ def chop_background_video(video_length):
background = VideoFileClip(f"assets/backgrounds/{choice}")
start_time, end_time = get_start_and_end_times(video_length, background.duration)
ffmpeg_extract_subclip(
f"assets/backgrounds/{choice}",
start_time,
end_time,
targetname="assets/temp/background.mp4",
)
try:
ffmpeg_extract_subclip(
f"assets/backgrounds/{choice}",
start_time,
end_time,
targetname="assets/temp/background.mp4",
)
except (OSError, IOError): # ffmpeg issue see #348
print_substep("FFMPEG issue. Trying again...")
with VideoFileClip(f"assets/backgrounds/{choice}") as video:
new = video.subclip(start_time, end_time)
new.write_videofile("assets/temp/background.mp4")
print_substep("Background video chopped successfully!", style="bold green")
return True

@ -26,7 +26,13 @@ console = Console()
W, H = 1080, 1920
def make_final_video(number_of_clips, length):
def make_final_video(number_of_clips:int, length:int):
"""Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp
Args:
number_of_clips (int): Index to end at when going through the screenshots
length (int): Length of the video
"""
print_step("Creating the final video 🎥")
VideoFileClip.reW = lambda clip: clip.resize(width=W)
VideoFileClip.reH = lambda clip: clip.resize(width=H)
@ -56,7 +62,9 @@ def make_final_video(number_of_clips, length):
# add title to video
image_clips = []
# Gather all images
if opacity is None or float(opacity) >= 1: # opacity not set or is set to one OR MORE
if (
opacity is None or float(opacity) >= 1
): # opacity not set or is set to one OR MORE
image_clips.insert(
0,
ImageClip("assets/temp/png/title.png")
@ -75,7 +83,9 @@ def make_final_video(number_of_clips, length):
)
for i in range(0, number_of_clips):
if opacity is None or float(opacity) >= 1: # opacity not set or is set to one OR MORE
if (
opacity is None or float(opacity) >= 1
): # opacity not set or is set to one OR MORE
image_clips.append(
ImageClip(f"assets/temp/png/comment_{i}.png")
.set_duration(audio_clips[i + 1].duration)
@ -101,41 +111,30 @@ def make_final_video(number_of_clips, length):
# .set_opacity(float(opacity)),
# )
# else:
image_concat = concatenate_videoclips(image_clips).set_position(("center", "center"))
image_concat = concatenate_videoclips(image_clips).set_position(
("center", "center")
)
image_concat.audio = audio_composite
final = CompositeVideoClip([background_clip, image_concat])
def get_video_title() -> str:
title = os.getenv("VIDEO_TITLE") or "final_video"
if len(title) <= 35:
return title
else:
return title[0:30] + "..."
filename = f"{get_video_title()}.mp4"
def save_data():
with open("./video_creation/data/videos.json", "r+") as raw_vids:
done_vids = json.load(raw_vids)
if str(subreddit.submission.id) in [video["id"] for video in done_vids]:
return # video already done but was specified to continue anyway in the .env file
payload = {
"id": str(os.getenv("VIDEO_ID")),
"time": str(int(time.time())),
"background_credit": str(os.getenv("background_credit")),
"reddit_title": str(os.getenv("VIDEO_TITLE")),
"filename": filename,
}
done_vids.append(payload)
raw_vids.seek(0)
json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4)
save_data()
save_data(filename)
if not exists("./results"):
print_substep("the results folder didn't exist so I made it")
os.mkdir("./results")
final.write_videofile("assets/temp/temp.mp4", verbose=False, threads=multiprocessing.cpu_count(), fps=30, audio_codec="aac", audio_bitrate="192k")
final.write_videofile(
"assets/temp/temp.mp4",
fps=30,
audio_codec="aac",
audio_bitrate="192k",
verbose=False,
threads=multiprocessing.cpu_count(),
)
ffmpeg_tools.ffmpeg_extract_subclip(
"assets/temp/temp.mp4", 0, length, targetname=f"results/{filename}"
)
@ -149,3 +148,36 @@ def make_final_video(number_of_clips, length):
print_step(
f"Reddit title: {os.getenv('VIDEO_TITLE')} \n Background Credit: {os.getenv('background_credit')}"
)
def save_data(filename:str):
"""Saves the videos that have already been generated to a JSON file in video_creation/data/videos.json
Args:
filename (str): The finished video title name
"""
with open("./video_creation/data/videos.json", "r+") as raw_vids:
done_vids = json.load(raw_vids)
if str(subreddit.submission.id) in [video["id"] for video in done_vids]:
return # video already done but was specified to continue anyway in the .env file
payload = {
"id": str(os.getenv("VIDEO_ID")),
"time": str(int(time.time())),
"background_credit": str(os.getenv("background_credit")),
"reddit_title": str(os.getenv("VIDEO_TITLE")),
"filename": filename,
}
done_vids.append(payload)
raw_vids.seek(0)
json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4)
def get_video_title() -> str:
"""Gets video title from env variable or gives it the name "final_video"
Returns:
str: Video title
"""
title = os.getenv("VIDEO_TITLE") or "final_video"
if len(title) <= 35:
return title
else:
return title[0:30] + "..."

@ -1,5 +1,6 @@
import json
from os import getenv
import os
from pathlib import Path
from playwright.async_api import async_playwright
@ -10,17 +11,21 @@ from utils.console import print_step, print_substep
import json
from rich.console import Console
import translators as ts
console = Console()
storymode = False
def download_screenshots_of_reddit_posts(reddit_object, screenshot_num):
"""Downloads screenshots of reddit posts as they are seen on the web.
def download_screenshots_of_reddit_posts(reddit_object:dict[str], screenshot_num:int):
"""Downloads screenshots of reddit posts as seen on the web. Downloads to assets/temp/png
Args:
reddit_object: The Reddit Object you received in askreddit.py
screenshot_num: The number of screenshots you want to download.
reddit_object (dict[str]): Reddit object received from reddit/subreddit.py
screenshot_num (int): Number of screenshots to downlaod
"""
print_step("Downloading screenshots of reddit posts...")
# ! Make sure the reddit screenshots folder exists
@ -51,7 +56,22 @@ def download_screenshots_of_reddit_posts(reddit_object, screenshot_num):
'[data-click-id="text"] button'
).click() # Remove "Click to see nsfw" Button in Screenshot
page.locator('[data-test-id="post-content"]').screenshot(path="assets/temp/png/title.png")
# translate code
if getenv("POSTLANG"):
print_substep("Translating post...")
texts_in_tl = ts.google(reddit_object["thread_title"], to_language=os.getenv("POSTLANG"))
page.evaluate(
'tl_content => document.querySelector(\'[data-test-id="post-content"] > div:nth-child(3) > div > div\').textContent = tl_content', texts_in_tl
)
else:
print_substep("Skipping translation...")
page.locator('[data-test-id="post-content"]').screenshot(
path="assets/temp/png/title.png"
)
if storymode:
page.locator('[data-click-id="text"]').screenshot(
path="assets/temp/png/story_content.png"
@ -60,7 +80,6 @@ def download_screenshots_of_reddit_posts(reddit_object, screenshot_num):
for idx, comment in track(
enumerate(reddit_object["comments"]), "Downloading screenshots..."
):
# Stop if we have reached the screenshot_num
if idx >= screenshot_num:
break
@ -69,7 +88,17 @@ def download_screenshots_of_reddit_posts(reddit_object, screenshot_num):
page.locator('[data-testid="content-gate"] button').click()
page.goto(f'https://reddit.com{comment["comment_url"]}', timeout=0)
# translate code
if getenv("POSTLANG"):
comment_tl = ts.google(comment["comment_body"], to_language=os.getenv("POSTLANG"))
page.evaluate(
'([tl_content, tl_id]) => document.querySelector(`#t1_${tl_id} > div:nth-child(2) > div > div[data-testid="comment"] > div`).textContent = tl_content', [comment_tl, comment['comment_id']]
)
page.locator(f"#t1_{comment['comment_id']}").screenshot(
path=f"assets/temp/png/comment_{idx}.png"
)
print_substep("Screenshots downloaded Successfully.", style="bold green")

@ -1,79 +1,67 @@
#!/usr/bin/env python3
from os import getenv
from pathlib import Path
#!/usr/bin/env python
import os
import sox
from mutagen import MutagenError
from mutagen.mp3 import MP3, HeaderNotFoundError
from rich.console import Console
from rich.progress import track
from TTS.swapper import TTS
from TTS.engine_wrapper import TTSEngine
from TTS.GTTS import GTTS
from TTS.streamlabs_polly import StreamlabsPolly
from TTS.aws_polly import AWSPolly
from TTS.TikTok import TikTok
from utils.console import print_table, print_step
from utils.console import print_step, print_substep
from utils.voice import sanitize_text
console = Console()
TTSProviders = {
"GoogleTranslate": GTTS,
"AWSPolly": AWSPolly,
"StreamlabsPolly": StreamlabsPolly,
"TikTok": TikTok,
}
VIDEO_LENGTH: int = 40 # secs
def save_text_to_mp3(reddit_obj):
"""Saves Text to MP3 files.
def save_text_to_mp3(reddit_obj:dict[str])->tuple[int,int]:
"""Saves text to MP3 files. Goes through the reddit_obj and generates the title MP3 file and a certain number of comments until the total amount of time exceeds VIDEO_LENGTH seconds.
Args:
reddit_obj : The reddit object you received from the reddit API in the askreddit.py file.
reddit_obj (dict[str]): Reddit object received from reddit API in reddit/subreddit.py
Returns:
tuple[int,int]: (total length of the audio, the number of comments audio was generated for)
"""
print_step("Saving Text to MP3 files...")
length = 0
# Create a folder for the mp3 files.
Path("assets/temp/mp3").mkdir(parents=True, exist_ok=True)
TextToSpeech = TTS()
TextToSpeech.tts(
sanitize_text(reddit_obj["thread_title"]),
filename="assets/temp/mp3/title.mp3",
random_speaker=False,
)
try:
length += MP3("assets/temp/mp3/title.mp3").info.length
except HeaderNotFoundError: # note to self AudioFileClip
length += sox.file_info.duration("assets/temp/mp3/title.mp3")
if getenv("STORYMODE").casefold() == "true":
TextToSpeech.tts(
sanitize_text(reddit_obj["thread_content"]),
filename="assets/temp/mp3/story_content.mp3",
random_speaker=False,
env = os.getenv("TTSCHOICE", "")
if env.casefold() in map(lambda _: _.casefold(), TTSProviders):
text_to_mp3 = TTSEngine(
get_case_insensitive_key_value(TTSProviders, env), reddit_obj
)
# 'story_content'
com = 0
for comment in track((reddit_obj["comments"]), "Saving..."):
# ! Stop creating mp3 files if the length is greater than VIDEO_LENGTH seconds. This can be longer
# but this is just a good_voices starting point
if length > VIDEO_LENGTH:
break
TextToSpeech.tts(
sanitize_text(comment["comment_body"]),
filename=f"assets/temp/mp3/{com}.mp3",
random_speaker=False,
else:
choice = ""
while True:
print_step("Please choose one of the following TTS providers: ")
print_table(TTSProviders)
choice = input("\n")
if choice.casefold() in map(lambda _: _.casefold(), TTSProviders):
break
print("Unknown Choice")
text_to_mp3 = TTSEngine(
get_case_insensitive_key_value(TTSProviders, choice), reddit_obj
)
try:
length += MP3(f"assets/temp/mp3/{com}.mp3").info.length
com += 1
except (HeaderNotFoundError, MutagenError, Exception):
try:
length += sox.file_info.duration(f"assets/temp/mp3/{com}.mp3")
com += 1
except (OSError, IOError):
print(
"would have removed"
f"assets/temp/mp3/{com}.mp3"
f"assets/temp/png/comment_{com}.png"
)
# remove(f"assets/temp/mp3/{com}.mp3")
# remove(f"assets/temp/png/comment_{com}.png")# todo might cause odd un-syncing
print_substep("Saved Text to MP3 files Successfully.", style="bold green")
# ! Return the index, so we know how many screenshots of comments we need to make.
return length, com
return text_to_mp3.run()
def get_case_insensitive_key_value(input_dict, key):
return next(
(
value
for dict_key, value in input_dict.items()
if dict_key.lower() == key.lower()
),
None,
)

Loading…
Cancel
Save