Merge branch 'develop' into Documentation

pull/648/head
Callum Leslie 2 years ago committed by GitHub
commit 83407aba74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,3 +1,4 @@
REDDIT_CLIENT_ID="" #fFAGRNJru1FTz70BzhT3Zg
#EXPLANATION the ID of your Reddit app of SCRIPT type
#RANGE 12:30
@ -62,12 +63,27 @@ OPACITY="1" #.8
#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" #en_us_002
VOICE="Matthew" # e.g. en_us_002
#EXPLANATION sets the voice the TTS uses
TTsChoice="polly" #polly
#EXPLANATION the backend used for TTS, default is polly
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"

@ -0,0 +1,28 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: bug
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**System (please complete the following information):**
- 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,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered** (optional)
A clear and concise description of any alternative solutions or features you've considered.
**Additional context** (optional)
Add any other context or screenshots about the feature request 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"

@ -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,21 +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. Add the directory of your SoX installation to the Path system variables (see https://github.com/elebumm/RedditVideoMakerBot/issues/613)
and move the added directory all the way to the top of the list of directories.
5. 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)
6. Run `playwright install` and `playwright install-deps`. (if this fails try adding python -m to the front of the command)
7. 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.
8. 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)()

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

@ -4,6 +4,7 @@ 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()
@ -28,6 +29,12 @@ def print_substep(text, style=""):
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,

@ -22,6 +22,6 @@ def sanitize_text(text: str) -> str:
# 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())

@ -49,7 +49,9 @@ 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:int):

@ -61,7 +61,9 @@ def make_final_video(number_of_clips:int, length:int):
# 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")
@ -80,7 +82,9 @@ def make_final_video(number_of_clips:int, length:int):
)
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)
@ -106,7 +110,9 @@ def make_final_video(number_of_clips:int, length:int):
# .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])
@ -120,7 +126,13 @@ def make_final_video(number_of_clips:int, length:int):
print_substep("the results folder didn't exist so I made it")
os.mkdir("./results")
final.write_videofile("assets/temp/temp.mp4", 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,
)
ffmpeg_tools.ffmpeg_extract_subclip(
"assets/temp/temp.mp4", 0, length, targetname=f"results/{filename}"
)

@ -1,5 +1,6 @@
import json
from os import getenv
import os
from pathlib import Path
from playwright.async_api import async_playwright
@ -10,6 +11,8 @@ from utils.console import print_step, print_substep
import json
from rich.console import Console
import translators as ts
console = Console()
storymode = False
@ -22,7 +25,7 @@ def download_screenshots_of_reddit_posts(reddit_object:dict[str], screenshot_num
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
@ -53,7 +56,22 @@ def download_screenshots_of_reddit_posts(reddit_object:dict[str], 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"
@ -62,7 +80,6 @@ def download_screenshots_of_reddit_posts(reddit_object:dict[str], 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
@ -71,7 +88,17 @@ def download_screenshots_of_reddit_posts(reddit_object:dict[str], 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,20 +1,26 @@
#!/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
@ -28,56 +34,34 @@ def save_text_to_mp3(reddit_obj:dict[str])->tuple[int,int]:
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