├── .dockerignore ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── autoblack.yml │ ├── codeql-analysis.yml │ ├── lint.yml │ └── stale.yml ├── .gitignore ├── .pylintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── GUI.py ├── GUI ├── backgrounds.html ├── index.html ├── layout.html ├── settings.html └── voices │ ├── amy.mp3 │ ├── br_001.mp3 │ ├── br_003.mp3 │ ├── br_004.mp3 │ ├── br_005.mp3 │ ├── brian.mp3 │ ├── de_001.mp3 │ ├── de_002.mp3 │ ├── emma.mp3 │ ├── en_au_001.mp3 │ ├── en_au_002.mp3 │ ├── en_uk_001.mp3 │ ├── en_uk_003.mp3 │ ├── en_us_001.mp3 │ ├── en_us_002.mp3 │ ├── en_us_006.mp3 │ ├── en_us_007.mp3 │ ├── en_us_009.mp3 │ ├── en_us_010.mp3 │ ├── en_us_c3po.mp3 │ ├── en_us_chewbacca.mp3 │ ├── en_us_ghostface.mp3 │ ├── en_us_rocket.mp3 │ ├── en_us_stitch.mp3 │ ├── en_us_stormtrooper.mp3 │ ├── es_002.mp3 │ ├── es_mx_002.mp3 │ ├── fr_001.mp3 │ ├── fr_002.mp3 │ ├── geraint.mp3 │ ├── id_001.mp3 │ ├── ivy.mp3 │ ├── joanna.mp3 │ ├── joey.mp3 │ ├── jp_001.mp3 │ ├── jp_003.mp3 │ ├── jp_005.mp3 │ ├── jp_006.mp3 │ ├── justin.mp3 │ ├── kendra.mp3 │ ├── kimberly.mp3 │ ├── kr_002.mp3 │ ├── kr_003.mp3 │ ├── kr_004.mp3 │ ├── matthew.mp3 │ ├── nicole.mp3 │ ├── raveena.mp3 │ ├── russell.mp3 │ └── salli.mp3 ├── LICENSE ├── README.md ├── TTS ├── GTTS.py ├── TikTok.py ├── __init__.py ├── aws_polly.py ├── elevenlabs.py ├── engine_wrapper.py ├── pyttsx.py └── streamlabs_polly.py ├── build.sh ├── fonts ├── LICENSE.txt ├── Roboto-Black.ttf ├── Roboto-Bold.ttf ├── Roboto-Medium.ttf └── Roboto-Regular.ttf ├── install.sh ├── main.py ├── ptt.py ├── reddit └── subreddit.py ├── requirements.txt ├── run.bat ├── run.sh ├── utils ├── .config.template.toml ├── __init__.py ├── ai_methods.py ├── background_audios.json ├── background_videos.json ├── cleanup.py ├── console.py ├── ffmpeg_install.py ├── gui_utils.py ├── id.py ├── imagenarator.py ├── playwright.py ├── posttextparser.py ├── settings.py ├── subreddit.py ├── thumbnail.py ├── version.py ├── videos.py └── voice.py └── video_creation ├── __init__.py ├── background.py ├── data ├── cookie-dark-mode.json └── cookie-light-mode.json ├── final_video.py ├── screenshot_downloader.py └── voices.py /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | title: "[Bug]: " 3 | labels: bug 4 | description: Report broken or incorrect behaviour 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | Thanks for taking the time to fill out a bug. 10 | Please note that this form is for bugs only! 11 | - type: textarea 12 | id: what-happened 13 | attributes: 14 | label: Describe the bug 15 | description: A clear and concise description of what the bug is. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Reproduction Steps 21 | description: > 22 | What you did to make it happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Expected behavior 28 | description: > 29 | A clear and concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Screenshots 35 | description: > 36 | If applicable, add screenshots to help explain your problem. 37 | validations: 38 | required: false 39 | - type: textarea 40 | attributes: 41 | label: System Information 42 | description: please fill your system informations 43 | value: > 44 | Operating System : [e.g. Windows 11] 45 | 46 | Python version : [e.g. Python 3.6] 47 | 48 | App version / Branch : [e.g. latest, V2.0, master, develop] 49 | validations: 50 | required: true 51 | - type: checkboxes 52 | attributes: 53 | label: Checklist 54 | description: > 55 | Let's make sure you've properly done due diligence when reporting this issue! 56 | options: 57 | - label: I have searched the open issues for duplicates. 58 | required: true 59 | - label: I have shown the entire traceback, if possible. 60 | required: true 61 | - type: textarea 62 | attributes: 63 | label: Additional Context 64 | description: Add any other context about the problem here. 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question 4 | about: Join our discord server to ask questions and discuss with maintainers and contributors. 5 | url: https://discord.gg/swqtb7AsNQ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: enhancement 4 | title: "[Feature]: " 5 | body: 6 | - type: input 7 | attributes: 8 | label: Summary 9 | description: > 10 | A short summary of what your feature request is. 11 | validations: 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Is your feature request related to a problem? 16 | description: > 17 | if yes, what becomes easier or possible when this feature is implemented? 18 | validations: 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Describe the solution you'd like 23 | description: > 24 | A clear and concise description of what you want to happen. 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Describe alternatives you've considered 30 | description: > 31 | A clear and concise description of any alternative solutions or features you've considered. 32 | validations: 33 | required: false 34 | 35 | 36 | - type: textarea 37 | attributes: 38 | label: Additional Context 39 | description: Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | 4 | 5 | # Issue Fixes 6 | 7 | 8 | 9 | None 10 | 11 | # Checklist: 12 | 13 | - [ ] I am pushing changes to the **develop** branch 14 | - [ ] I am using the recommended development environment 15 | - [ ] I have performed a self-review of my own code 16 | - [ ] I have commented my code, particularly in hard-to-understand areas 17 | - [ ] I have formatted and linted my code using python-black and pylint 18 | - [ ] I have cleaned up unnecessary files 19 | - [ ] My changes generate no new warnings 20 | - [ ] My changes follow the existing code-style 21 | - [ ] My changes are relevant to the project 22 | 23 | # Any other information (e.g how to test the changes) 24 | 25 | None 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | target-branch: "develop" 13 | -------------------------------------------------------------------------------- /.github/workflows/autoblack.yml: -------------------------------------------------------------------------------- 1 | # GitHub Action that uses Black to reformat the Python code in an incoming pull request. 2 | # If all Python code in the pull request is compliant with Black then this Action does nothing. 3 | # Othewrwise, Black is run and its changes are committed back to the incoming pull request. 4 | # https://github.com/cclauss/autoblack 5 | 6 | name: autoblack 7 | on: 8 | push: 9 | branches: ["develop"] 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.9 19 | - name: Install Black 20 | run: pip install black 21 | - name: Run black --check . 22 | run: black --check . 23 | - name: If needed, commit black changes to the pull request 24 | if: failure() 25 | run: | 26 | black . --line-length 101 27 | git config --global user.name github-actions 28 | git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com 29 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 30 | git checkout $GITHUB_HEAD_REF 31 | git commit -am "fixup: Format Python code with Black" 32 | git push origin HEAD:develop 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | 2 | # For most projects, this workflow file will not need changing; you simply need 3 | # to commit it to your repository. 4 | # 5 | # You may wish to alter this file to override the set of languages analyzed, 6 | # or to provide custom queries or build logic. 7 | # 8 | # ******** NOTE ******** 9 | # We have attempted to detect the languages in your repository. Please check 10 | # the `language` matrix defined below to confirm you have the correct set of 11 | # supported CodeQL languages. 12 | # 13 | name: "CodeQL" 14 | 15 | on: 16 | push: 17 | branches: [ "master", "develop" ] 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: [ "master", "develop" ] 21 | schedule: 22 | - cron: '16 14 * * 3' 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'python' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 38 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | 53 | # 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 54 | # queries: security-extended,security-and-quality 55 | 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v2 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 64 | 65 | # If the Autobuild fails above, remove it and uncomment the following three lines. 66 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 67 | 68 | # - run: | 69 | # echo "Run, Build Application using script" 70 | # ./location_of_script_within_repo/buildscript.sh 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v2 74 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable 11 | with: 12 | options: "--line-length 101" 13 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Stale issue handler' 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | 9 | stale: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/stale@v9 16 | id: stale-issue 17 | name: stale-issue 18 | with: 19 | # general settings 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | stale-issue-message: 'This issue is stale because it has been open 7 days with no activity. Remove stale label or comment, or this will be closed in 10 days.' 22 | close-issue-message: 'Issue closed due to being stale. Please reopen if issue persists in latest version.' 23 | days-before-stale: 6 24 | days-before-close: 12 25 | stale-issue-label: 'stale' 26 | close-issue-label: 'outdated' 27 | exempt-issue-labels: 'enhancement,keep,blocked' 28 | exempt-all-issue-milestones: true 29 | operations-per-run: 300 30 | remove-stale-when-updated: true 31 | ascending: true 32 | #debug-only: true 33 | 34 | - uses: actions/stale@v9 35 | id: stale-pr 36 | name: stale-pr 37 | with: 38 | # general settings 39 | repo-token: ${{ secrets.GITHUB_TOKEN }} 40 | stale-pr-message: 'This pull request is stale as it has been open for 7 days with no activity. Remove stale label or comment, or this will be closed in 10 days.' 41 | close-pr-message: 'Pull request closed due to being stale.' 42 | days-before-stale: 10 43 | days-before-close: 20 44 | close-pr-label: 'outdated' 45 | stale-pr-label: 'stale' 46 | exempt-pr-labels: 'keep,blocked,before next release,after next release' 47 | exempt-all-pr-milestones: true 48 | operations-per-run: 300 49 | remove-stale-when-updated: true 50 | #debug-only: true 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 157 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 158 | 159 | # User-specific stuff 160 | .idea/**/workspace.xml 161 | .idea/**/tasks.xml 162 | .idea/**/usage.statistics.xml 163 | .idea/**/dictionaries 164 | .idea/**/shelf 165 | 166 | # AWS User-specific 167 | .idea/**/aws.xml 168 | 169 | # Generated files 170 | .idea/**/contentModel.xml 171 | 172 | # Sensitive or high-churn files 173 | .idea/**/dataSources/ 174 | .idea/**/dataSources.ids 175 | .idea/**/dataSources.local.xml 176 | .idea/**/sqlDataSources.xml 177 | .idea/**/dynamic.xml 178 | .idea/**/uiDesigner.xml 179 | .idea/**/dbnavigator.xml 180 | 181 | # Gradle 182 | .idea/**/gradle.xml 183 | .idea/**/libraries 184 | 185 | # Gradle and Maven with auto-import 186 | # When using Gradle or Maven with auto-import, you should exclude module files, 187 | # since they will be recreated, and may cause churn. Uncomment if using 188 | # auto-import. 189 | # .idea/artifacts 190 | # .idea/compiler.xml 191 | # .idea/jarRepositories.xml 192 | # .idea/modules.xml 193 | # .idea/*.iml 194 | # .idea/modules 195 | # *.iml 196 | # *.ipr 197 | 198 | # CMake 199 | cmake-build-*/ 200 | 201 | # Mongo Explorer plugin 202 | .idea/**/mongoSettings.xml 203 | 204 | # File-based project format 205 | *.iws 206 | 207 | # IntelliJ 208 | out/ 209 | 210 | # mpeltonen/sbt-idea plugin 211 | .idea_modules/ 212 | 213 | # JIRA plugin 214 | atlassian-ide-plugin.xml 215 | 216 | # Cursive Clojure plugin 217 | .idea/replstate.xml 218 | 219 | # SonarLint plugin 220 | .idea/sonarlint/ 221 | 222 | # Crashlytics plugin (for Android Studio and IntelliJ) 223 | com_crashlytics_export_strings.xml 224 | crashlytics.properties 225 | crashlytics-build.properties 226 | fabric.properties 227 | 228 | # Editor-based Rest Client 229 | .idea/httpRequests 230 | 231 | # Android studio 3.1+ serialized cache file 232 | .idea/caches/build_file_checksums.ser 233 | 234 | assets/ 235 | /.vscode 236 | out 237 | .DS_Store 238 | .setup-done-before 239 | results/* 240 | reddit-bot-351418-5560ebc49cac.json 241 | /.idea 242 | *.pyc 243 | video_creation/data/videos.json 244 | video_creation/data/envvars.txt 245 | 246 | config.toml 247 | *.exe -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at the [discord server](https://discord.gg/yqNvvDMYpq). 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to Reddit Video Maker Bot 🎥 3 | 4 | Thanks for taking the time to contribute! ❤️ 5 | 6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for the maintainers and smooth out the experience for all involved. We are looking forward to your contributions. 🎉 7 | 8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 9 | > 10 | > - ⭐ Star the project 11 | > - 📣 Tweet about it 12 | > - 🌲 Refer this project in your project's readme 13 | 14 | ## Table of Contents 15 | 16 | - [Contributing to Reddit Video Maker Bot 🎥](#contributing-to-reddit-video-maker-bot-) 17 | - [Table of Contents](#table-of-contents) 18 | - [I Have a Question](#i-have-a-question) 19 | - [I Want To Contribute](#i-want-to-contribute) 20 | - [Reporting Bugs](#reporting-bugs) 21 | - [How Do I Submit a Good Bug Report?](#how-do-i-submit-a-good-bug-report) 22 | - [Suggesting Enhancements](#suggesting-enhancements) 23 | - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) 24 | - [Your First Code Contribution](#your-first-code-contribution) 25 | - [Your environment](#your-environment) 26 | - [Making your first PR](#making-your-first-pr) 27 | - [Improving The Documentation](#improving-the-documentation) 28 | 29 | ## I Have a Question 30 | 31 | > If you want to ask a question, we assume that you have read the available [Documentation](https://reddit-video-maker-bot.netlify.app/). 32 | 33 | Before you ask a question, it is best to search for existing [Issues](https://github.com/elebumm/RedditVideoMakerBot/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 34 | 35 | If you then still feel the need to ask a question and need clarification, we recommend the following: 36 | 37 | - Open an [Issue](https://github.com/elebumm/RedditVideoMakerBot/issues/new). 38 | - Provide as much context as you can about what you're running into. 39 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 40 | 41 | We will then take care of the issue as soon as possible. 42 | 43 | Additionally, there is a [Discord Server](https://discord.gg/swqtb7AsNQ) for any questions you may have 44 | 45 | ## I Want To Contribute 46 | 47 | ### Reporting Bugs 48 | 49 |

Before Submitting a Bug Report

50 | 51 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 52 | 53 | - Make sure that you are using the latest version. 54 | - Determine if your bug is really a bug and not an error on your side e.g., using incompatible environment components/versions (Make sure that you have read the [documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/). If you are looking for support, you might want to check [this section](#i-have-a-question)). 55 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [issues](https://github.com/elebumm/RedditVideoMakerBot/). 56 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue - you probably aren't the first to get the error! 57 | - Collect information about the bug: 58 | - Stack trace (Traceback) - preferably formatted in a code block. 59 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 60 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 61 | - Your input and the output 62 | - Is the issue reproducible? Does it exist in previous versions? 63 | 64 | #### How Do I Submit a Good Bug Report? 65 | 66 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 67 | 68 | - Open an [Issue](https://github.com/elebumm/RedditVideoMakerBot/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 69 | - Explain the behavior you would expect and the actual behavior. 70 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 71 | - Provide the information you collected in the previous section. 72 | 73 | Once it's filed: 74 | 75 | - The project team will label the issue accordingly. 76 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will try to support you as best as they can, but you may not receive an instant. 77 | - If the team discovers that this is an issue it will be marked `bug` or `error`, as well as possibly other tags relating to the nature of the error), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 78 |
79 | 80 | ### Suggesting Enhancements 81 | 82 | This section guides you through submitting an enhancement suggestion for Reddit Video Maker Bot, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 83 | 84 |

Before Submitting an Enhancement

85 | 86 | - Make sure that you are using the latest version. 87 | - Read the [documentation](https://luka-hietala.gitbook.io/documentation-for-the-reddit-bot/) carefully and find out if the functionality is already covered, maybe by an individual configuration. 88 | - Perform a [search](https://github.com/elebumm/RedditVideoMakerBot/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 89 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. 90 | 91 | #### How Do I Submit a Good Enhancement Suggestion? 92 | 93 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/elebumm/RedditVideoMakerBot/issues). 94 | 95 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 96 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 97 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 98 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 99 | - **Explain why this enhancement would be useful** to most users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 100 | 101 |
102 | 103 | ### Your First Code Contribution 104 | 105 | #### Your environment 106 | 107 | You development environment should follow the requirements stated in the [README file](README.md). If you are not using the specified versions, **please reference this in your pull request**, so reviewers can test your code on both versions. 108 | 109 | #### Setting up your development repository 110 | 111 | These steps are only specified for beginner developers trying to contribute to this repository. 112 | If you know how to make a fork and clone, you can skip these steps. 113 | 114 | Before contributing, follow these steps (if you are a beginner) 115 | 116 | - Create a fork of this repository to your personal account 117 | - Clone the repo to your computer 118 | - Make sure that you have all dependencies installed 119 | - Run `python main.py` to make sure that the program is working 120 | - Now, you are all setup to contribute your own features to this repo! 121 | 122 | Even if you are a beginner to working with python or contributing to open source software, 123 | don't worry! You can still try contributing even to the documentation! 124 | 125 | ("Setting up your development repository" was written by a beginner developer themselves!) 126 | 127 | 128 | #### Making your first PR 129 | 130 | When making your PR, follow these guidelines: 131 | 132 | - Your branch has a base of _develop_, **not** _master_ 133 | - You are merging your branch into the _develop_ branch 134 | - You link any issues that are resolved or fixed by your changes. (this is done by typing "Fixes #\") in your pull request 135 | - Where possible, you have used `git pull --rebase`, to avoid creating unnecessary merge commits 136 | - You have meaningful commits, and if possible, follow the commit style guide of `type: explanation` 137 | - Here are the commit types: 138 | - **feat** - a new feature 139 | - **fix** - a bug fix 140 | - **docs** - a change to documentation / commenting 141 | - **style** - formatting changes - does not impact code 142 | - **refactor** - refactored code 143 | - **chore** - updating configs, workflows etc - does not impact code 144 | 145 | ### Improving The Documentation 146 | 147 | All updates to the documentation should be made in a pull request to [this repo](https://github.com/LukaHietala/RedditVideoMakerBot-website) 148 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.9-slim 2 | 3 | RUN apt update 4 | RUN apt-get install -y ffmpeg 5 | RUN apt install python3-pip -y 6 | 7 | RUN mkdir /app 8 | ADD . /app 9 | WORKDIR /app 10 | RUN pip install -r requirements.txt 11 | 12 | # tricks for pytube : https://github.com/elebumm/RedditVideoMakerBot/issues/142 13 | # (NOTE : This is no longer useful since pytube was removed from the dependencies) 14 | # RUN sed -i 's/re.compile(r"^\\w+\\W")/re.compile(r"^\\$*\\w+\\W")/' /usr/local/lib/python3.8/dist-packages/pytube/cipher.py 15 | 16 | CMD ["python3", "main.py"] 17 | -------------------------------------------------------------------------------- /GUI.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | from pathlib import Path 3 | 4 | # Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump" 5 | import tomlkit 6 | from flask import ( 7 | Flask, 8 | redirect, 9 | render_template, 10 | request, 11 | send_from_directory, 12 | url_for, 13 | ) 14 | 15 | import utils.gui_utils as gui 16 | 17 | # Set the hostname 18 | HOST = "localhost" 19 | # Set the port number 20 | PORT = 4000 21 | 22 | # Configure application 23 | app = Flask(__name__, template_folder="GUI") 24 | 25 | # Configure secret key only to use 'flash' 26 | app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' 27 | 28 | 29 | # Ensure responses aren't cached 30 | @app.after_request 31 | def after_request(response): 32 | response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 33 | response.headers["Expires"] = 0 34 | response.headers["Pragma"] = "no-cache" 35 | return response 36 | 37 | 38 | # Display index.html 39 | @app.route("/") 40 | def index(): 41 | return render_template("index.html", file="videos.json") 42 | 43 | 44 | @app.route("/backgrounds", methods=["GET"]) 45 | def backgrounds(): 46 | return render_template("backgrounds.html", file="backgrounds.json") 47 | 48 | 49 | @app.route("/background/add", methods=["POST"]) 50 | def background_add(): 51 | # Get form values 52 | youtube_uri = request.form.get("youtube_uri").strip() 53 | filename = request.form.get("filename").strip() 54 | citation = request.form.get("citation").strip() 55 | position = request.form.get("position").strip() 56 | 57 | gui.add_background(youtube_uri, filename, citation, position) 58 | 59 | return redirect(url_for("backgrounds")) 60 | 61 | 62 | @app.route("/background/delete", methods=["POST"]) 63 | def background_delete(): 64 | key = request.form.get("background-key") 65 | gui.delete_background(key) 66 | 67 | return redirect(url_for("backgrounds")) 68 | 69 | 70 | @app.route("/settings", methods=["GET", "POST"]) 71 | def settings(): 72 | config_load = tomlkit.loads(Path("config.toml").read_text()) 73 | config = gui.get_config(config_load) 74 | 75 | # Get checks for all values 76 | checks = gui.get_checks() 77 | 78 | if request.method == "POST": 79 | # Get data from form as dict 80 | data = request.form.to_dict() 81 | 82 | # Change settings 83 | config = gui.modify_settings(data, config_load, checks) 84 | 85 | return render_template("settings.html", file="config.toml", data=config, checks=checks) 86 | 87 | 88 | # Make videos.json accessible 89 | @app.route("/videos.json") 90 | def videos_json(): 91 | return send_from_directory("video_creation/data", "videos.json") 92 | 93 | 94 | # Make backgrounds.json accessible 95 | @app.route("/backgrounds.json") 96 | def backgrounds_json(): 97 | return send_from_directory("utils", "backgrounds.json") 98 | 99 | 100 | # Make videos in results folder accessible 101 | @app.route("/results/") 102 | def results(name): 103 | return send_from_directory("results", name, as_attachment=True) 104 | 105 | 106 | # Make voices samples in voices folder accessible 107 | @app.route("/voices/") 108 | def voices(name): 109 | return send_from_directory("GUI/voices", name, as_attachment=True) 110 | 111 | 112 | # Run browser and start the app 113 | if __name__ == "__main__": 114 | webbrowser.open(f"http://{HOST}:{PORT}", new=2) 115 | print("Website opened in new tab. Refresh if it didn't load.") 116 | app.run(port=PORT) 117 | -------------------------------------------------------------------------------- /GUI/backgrounds.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block main %} 3 | 4 | 5 | 24 | 25 | 26 | 97 | 98 |
99 |
100 |
101 | 102 |
103 |
104 | 106 |
107 |
108 | 112 |
113 |
114 | 115 |
116 | 117 |
118 |
119 |
120 |
121 | 122 | 262 | 263 | {% endblock %} -------------------------------------------------------------------------------- /GUI/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block main %} 3 | 4 |
5 |
6 |
7 | 8 |
9 |
10 | 12 |
13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 |
21 | 22 | 182 | {% endblock %} -------------------------------------------------------------------------------- /GUI/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | RedditVideoMakerBot 8 | 10 | 11 | 12 | 13 | 67 | 68 | 69 | 71 | 74 | 77 | 78 | 79 | 80 | 81 |
82 | {% if get_flashed_messages() %} 83 | {% for category, message in get_flashed_messages(with_categories=true) %} 84 | 85 | {% if category == "error" %} 86 | 89 | 90 | {% else %} 91 | 94 | {% endif %} 95 | {% endfor %} 96 | {% endif %} 97 | 128 |
129 | 130 | {% block main %}{% endblock %} 131 | 132 |
133 |
134 |

135 | Back to top 136 |

137 |

Album 138 | Example 139 | Theme by © Bootstrap. Developers and Maintainers

142 |

If your data is not refreshing, try to hard reload(Ctrl + F5) or click this and visit your local 143 | 144 | {{ file }} file. 145 |

146 |
147 |
148 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /GUI/voices/amy.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/amy.mp3 -------------------------------------------------------------------------------- /GUI/voices/br_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/br_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/br_003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/br_003.mp3 -------------------------------------------------------------------------------- /GUI/voices/br_004.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/br_004.mp3 -------------------------------------------------------------------------------- /GUI/voices/br_005.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/br_005.mp3 -------------------------------------------------------------------------------- /GUI/voices/brian.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/brian.mp3 -------------------------------------------------------------------------------- /GUI/voices/de_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/de_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/de_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/de_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/emma.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/emma.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_au_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_au_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_au_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_au_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_uk_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_uk_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_uk_003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_uk_003.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_006.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_006.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_007.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_007.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_009.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_009.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_010.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_010.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_c3po.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_c3po.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_chewbacca.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_chewbacca.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_ghostface.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_ghostface.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_rocket.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_rocket.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_stitch.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_stitch.mp3 -------------------------------------------------------------------------------- /GUI/voices/en_us_stormtrooper.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/en_us_stormtrooper.mp3 -------------------------------------------------------------------------------- /GUI/voices/es_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/es_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/es_mx_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/es_mx_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/fr_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/fr_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/fr_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/fr_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/geraint.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/geraint.mp3 -------------------------------------------------------------------------------- /GUI/voices/id_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/id_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/ivy.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/ivy.mp3 -------------------------------------------------------------------------------- /GUI/voices/joanna.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/joanna.mp3 -------------------------------------------------------------------------------- /GUI/voices/joey.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/joey.mp3 -------------------------------------------------------------------------------- /GUI/voices/jp_001.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/jp_001.mp3 -------------------------------------------------------------------------------- /GUI/voices/jp_003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/jp_003.mp3 -------------------------------------------------------------------------------- /GUI/voices/jp_005.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/jp_005.mp3 -------------------------------------------------------------------------------- /GUI/voices/jp_006.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/jp_006.mp3 -------------------------------------------------------------------------------- /GUI/voices/justin.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/justin.mp3 -------------------------------------------------------------------------------- /GUI/voices/kendra.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/kendra.mp3 -------------------------------------------------------------------------------- /GUI/voices/kimberly.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/kimberly.mp3 -------------------------------------------------------------------------------- /GUI/voices/kr_002.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/kr_002.mp3 -------------------------------------------------------------------------------- /GUI/voices/kr_003.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/kr_003.mp3 -------------------------------------------------------------------------------- /GUI/voices/kr_004.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/kr_004.mp3 -------------------------------------------------------------------------------- /GUI/voices/matthew.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/matthew.mp3 -------------------------------------------------------------------------------- /GUI/voices/nicole.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/nicole.mp3 -------------------------------------------------------------------------------- /GUI/voices/raveena.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/raveena.mp3 -------------------------------------------------------------------------------- /GUI/voices/russell.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/russell.mp3 -------------------------------------------------------------------------------- /GUI/voices/salli.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/GUI/voices/salli.mp3 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reddit Video Maker Bot 🎥 2 | 3 | All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨. 4 | 5 | Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ## Video Explainer 17 | 18 | [![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png) 19 | ](https://www.youtube.com/watch?v=3gjcY_00U1w) 20 | 21 | ## Motivation 🤔 22 | 23 | These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort. 24 | The only original thing being done is the editing and gathering of all materials... 25 | 26 | ... but what if we can automate that process? 🤔 27 | 28 | ## Disclaimers 🚨 29 | 30 | - **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that 31 | you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues. 32 | 33 | ## Requirements 34 | 35 | - Python 3.10 36 | - Playwright (this should install automatically in installation) 37 | 38 | ## Installation 👩‍💻 39 | 40 | 1. Clone this repository 41 | 2. Run `pip install -r requirements.txt` 42 | 3. Run `python -m playwright install` and `python -m playwright install-deps` 43 | 44 | **EXPERIMENTAL!!!!** 45 | 46 | On macOS and Linux (debian, arch, fedora and centos, and based on those), you can run an install script that will automatically install steps 1 to 3. (requires bash) 47 | 48 | `bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)` 49 | 50 | This can also be used to update the installation 51 | 52 | 4. Run `python main.py` 53 | 5. Visit [the Reddit Apps page.](https://www.reddit.com/prefs/apps), and set up an app that is a "script". Paste any URL in redirect URL. Ex:google.com 54 | 6. The bot will ask you to fill in your details to connect to the Reddit API, and configure the bot to your liking 55 | 7. Enjoy 😎 56 | 8. If you need to reconfigure the bot, simply open the `config.toml` file and delete the lines that need to be changed. On the next run of the bot, it will help you reconfigure those options. 57 | 58 | (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) 59 | 60 | If you want to read more detailed guide about the bot, please refer to the [documentation](https://reddit-video-maker-bot.netlify.app/) 61 | 62 | ## Video 63 | 64 | https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4 65 | 66 | ## Contributing & Ways to improve 📈 67 | 68 | In its current state, this bot does exactly what it needs to do. However, improvements can always be made! 69 | 70 | I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute! 71 | 72 | - [ ] Creating better documentation and adding a command line interface. 73 | - [x] Allowing the user to choose background music for their videos. 74 | - [x] Allowing users to choose a reddit thread instead of being randomized. 75 | - [x] Allowing users to choose a background that is picked instead of the Minecraft one. 76 | - [x] Allowing users to choose between any subreddit. 77 | - [x] Allowing users to change voice. 78 | - [x] Checks if a video has already been created 79 | - [x] Light and Dark modes 80 | - [x] NSFW post filter 81 | 82 | Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information. 83 | 84 | ### For any questions or support join the [Discord](https://discord.gg/WBQT52RrHV) server 85 | 86 | ## Developers and maintainers. 87 | 88 | Elebumm (Lewis#6305) - https://github.com/elebumm (Founder) 89 | 90 | Jason (personality.json) - https://github.com/JasonLovesDoggo (Maintainer) 91 | 92 | Simon (OpenSourceSimon) - https://github.com/OpenSourceSimon 93 | 94 | CallumIO (c.#6837) - https://github.com/CallumIO 95 | 96 | Verq (Verq#2338) - https://github.com/CordlessCoder 97 | 98 | LukaHietala (Pix.#0001) - https://github.com/LukaHietala 99 | 100 | Freebiell (Freebie#3263) - https://github.com/FreebieII 101 | 102 | Aman Raza (electro199#8130) - https://github.com/electro199 103 | 104 | 105 | ## LICENSE 106 | [Roboto Fonts](https://fonts.google.com/specimen/Roboto/about) are licensed under [Apache License V2](https://www.apache.org/licenses/LICENSE-2.0) 107 | -------------------------------------------------------------------------------- /TTS/GTTS.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from gtts import gTTS 4 | 5 | from utils import settings 6 | 7 | 8 | class GTTS: 9 | def __init__(self): 10 | self.max_chars = 5000 11 | self.voices = [] 12 | 13 | def run(self, text, filepath): 14 | tts = gTTS( 15 | text=text, 16 | lang=settings.config["reddit"]["thread"]["post_lang"] or "en", 17 | slow=False, 18 | ) 19 | tts.save(filepath) 20 | 21 | def randomvoice(self): 22 | return random.choice(self.voices) 23 | -------------------------------------------------------------------------------- /TTS/TikTok.py: -------------------------------------------------------------------------------- 1 | # documentation for tiktok api: https://github.com/oscie57/tiktok-voice/wiki 2 | import base64 3 | import random 4 | import time 5 | from typing import Optional, Final 6 | 7 | import requests 8 | 9 | from utils import settings 10 | 11 | __all__ = ["TikTok", "TikTokTTSException"] 12 | 13 | disney_voices: Final[tuple] = ( 14 | "en_us_ghostface", # Ghost Face 15 | "en_us_chewbacca", # Chewbacca 16 | "en_us_c3po", # C3PO 17 | "en_us_stitch", # Stitch 18 | "en_us_stormtrooper", # Stormtrooper 19 | "en_us_rocket", # Rocket 20 | "en_female_madam_leota", # Madame Leota 21 | "en_male_ghosthost", # Ghost Host 22 | "en_male_pirate", # pirate 23 | ) 24 | 25 | eng_voices: Final[tuple] = ( 26 | "en_au_001", # English AU - Female 27 | "en_au_002", # English AU - Male 28 | "en_uk_001", # English UK - Male 1 29 | "en_uk_003", # English UK - Male 2 30 | "en_us_001", # English US - Female (Int. 1) 31 | "en_us_002", # English US - Female (Int. 2) 32 | "en_us_006", # English US - Male 1 33 | "en_us_007", # English US - Male 2 34 | "en_us_009", # English US - Male 3 35 | "en_us_010", # English US - Male 4 36 | "en_male_narration", # Narrator 37 | "en_male_funny", # Funny 38 | "en_female_emotional", # Peaceful 39 | "en_male_cody", # Serious 40 | ) 41 | 42 | non_eng_voices: Final[tuple] = ( 43 | # Western European voices 44 | "fr_001", # French - Male 1 45 | "fr_002", # French - Male 2 46 | "de_001", # German - Female 47 | "de_002", # German - Male 48 | "es_002", # Spanish - Male 49 | "it_male_m18", # Italian - Male 50 | # South american voices 51 | "es_mx_002", # Spanish MX - Male 52 | "br_001", # Portuguese BR - Female 1 53 | "br_003", # Portuguese BR - Female 2 54 | "br_004", # Portuguese BR - Female 3 55 | "br_005", # Portuguese BR - Male 56 | # asian voices 57 | "id_001", # Indonesian - Female 58 | "jp_001", # Japanese - Female 1 59 | "jp_003", # Japanese - Female 2 60 | "jp_005", # Japanese - Female 3 61 | "jp_006", # Japanese - Male 62 | "kr_002", # Korean - Male 1 63 | "kr_003", # Korean - Female 64 | "kr_004", # Korean - Male 2 65 | ) 66 | 67 | vocals: Final[tuple] = ( 68 | "en_female_f08_salut_damour", # Alto 69 | "en_male_m03_lobby", # Tenor 70 | "en_male_m03_sunshine_soon", # Sunshine Soon 71 | "en_female_f08_warmy_breeze", # Warmy Breeze 72 | "en_female_ht_f08_glorious", # Glorious 73 | "en_male_sing_funny_it_goes_up", # It Goes Up 74 | "en_male_m2_xhxs_m03_silly", # Chipmunk 75 | "en_female_ht_f08_wonderful_world", # Dramatic 76 | ) 77 | 78 | 79 | class TikTok: 80 | """TikTok Text-to-Speech Wrapper""" 81 | 82 | def __init__(self): 83 | headers = { 84 | "User-Agent": "com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; " 85 | "Build/NRD90M;tt-ok/3.12.13.1)", 86 | "Cookie": f"sessionid={settings.config['settings']['tts']['tiktok_sessionid']}", 87 | } 88 | 89 | self.URI_BASE = "https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/" 90 | self.max_chars = 200 91 | 92 | self._session = requests.Session() 93 | # set the headers to the session, so we don't have to do it for every request 94 | self._session.headers = headers 95 | 96 | def run(self, text: str, filepath: str, random_voice: bool = False): 97 | if random_voice: 98 | voice = self.random_voice() 99 | else: 100 | # if tiktok_voice is not set in the config file, then use a random voice 101 | voice = settings.config["settings"]["tts"].get("tiktok_voice", None) 102 | 103 | # get the audio from the TikTok API 104 | data = self.get_voices(voice=voice, text=text) 105 | 106 | # check if there was an error in the request 107 | status_code = data["status_code"] 108 | if status_code != 0: 109 | raise TikTokTTSException(status_code, data["message"]) 110 | 111 | # decode data from base64 to binary 112 | try: 113 | raw_voices = data["data"]["v_str"] 114 | except: 115 | print( 116 | "The TikTok TTS returned an invalid response. Please try again later, and report this bug." 117 | ) 118 | raise TikTokTTSException(0, "Invalid response") 119 | decoded_voices = base64.b64decode(raw_voices) 120 | 121 | # write voices to specified filepath 122 | with open(filepath, "wb") as out: 123 | out.write(decoded_voices) 124 | 125 | def get_voices(self, text: str, voice: Optional[str] = None) -> dict: 126 | """If voice is not passed, the API will try to use the most fitting voice""" 127 | # sanitize text 128 | text = text.replace("+", "plus").replace("&", "and").replace("r/", "") 129 | 130 | # prepare url request 131 | params = {"req_text": text, "speaker_map_type": 0, "aid": 1233} 132 | 133 | if voice is not None: 134 | params["text_speaker"] = voice 135 | 136 | # send request 137 | try: 138 | response = self._session.post(self.URI_BASE, params=params) 139 | except ConnectionError: 140 | time.sleep(random.randrange(1, 7)) 141 | response = self._session.post(self.URI_BASE, params=params) 142 | 143 | return response.json() 144 | 145 | @staticmethod 146 | def random_voice() -> str: 147 | return random.choice(eng_voices) 148 | 149 | 150 | class TikTokTTSException(Exception): 151 | def __init__(self, code: int, message: str): 152 | self._code = code 153 | self._message = message 154 | 155 | def __str__(self) -> str: 156 | if self._code == 1: 157 | return f"Code: {self._code}, reason: probably the aid value isn't correct, message: {self._message}" 158 | 159 | if self._code == 2: 160 | return f"Code: {self._code}, reason: the text is too long, message: {self._message}" 161 | 162 | if self._code == 4: 163 | return f"Code: {self._code}, reason: the speaker doesn't exist, message: {self._message}" 164 | 165 | return f"Code: {self._message}, reason: unknown, message: {self._message}" 166 | -------------------------------------------------------------------------------- /TTS/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/TTS/__init__.py -------------------------------------------------------------------------------- /TTS/aws_polly.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sys 3 | 4 | from boto3 import Session 5 | from botocore.exceptions import BotoCoreError, ClientError, ProfileNotFound 6 | 7 | from utils import settings 8 | 9 | voices = [ 10 | "Brian", 11 | "Emma", 12 | "Russell", 13 | "Joey", 14 | "Matthew", 15 | "Joanna", 16 | "Kimberly", 17 | "Amy", 18 | "Geraint", 19 | "Nicole", 20 | "Justin", 21 | "Ivy", 22 | "Kendra", 23 | "Salli", 24 | "Raveena", 25 | ] 26 | 27 | 28 | class AWSPolly: 29 | def __init__(self): 30 | self.max_chars = 3000 31 | self.voices = voices 32 | 33 | def run(self, text, filepath, random_voice: bool = False): 34 | try: 35 | session = Session(profile_name="polly") 36 | polly = session.client("polly") 37 | if random_voice: 38 | voice = self.randomvoice() 39 | else: 40 | if not settings.config["settings"]["tts"]["aws_polly_voice"]: 41 | raise ValueError( 42 | f"Please set the TOML variable AWS_VOICE to a valid voice. options are: {voices}" 43 | ) 44 | voice = str(settings.config["settings"]["tts"]["aws_polly_voice"]).capitalize() 45 | try: 46 | # Request speech synthesis 47 | response = polly.synthesize_speech( 48 | Text=text, OutputFormat="mp3", VoiceId=voice, Engine="neural" 49 | ) 50 | except (BotoCoreError, ClientError) as error: 51 | # The service returned an error, exit gracefully 52 | print(error) 53 | sys.exit(-1) 54 | 55 | # Access the audio stream from the response 56 | if "AudioStream" in response: 57 | file = open(filepath, "wb") 58 | file.write(response["AudioStream"].read()) 59 | file.close() 60 | # print_substep(f"Saved Text {idx} to MP3 files successfully.", style="bold green") 61 | 62 | else: 63 | # The response didn't contain audio data, exit gracefully 64 | print("Could not stream audio") 65 | sys.exit(-1) 66 | except ProfileNotFound: 67 | print("You need to install the AWS CLI and configure your profile") 68 | print( 69 | """ 70 | Linux: https://docs.aws.amazon.com/polly/latest/dg/setup-aws-cli.html 71 | Windows: https://docs.aws.amazon.com/polly/latest/dg/install-voice-plugin2.html 72 | """ 73 | ) 74 | sys.exit(-1) 75 | 76 | def randomvoice(self): 77 | return random.choice(self.voices) 78 | -------------------------------------------------------------------------------- /TTS/elevenlabs.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from elevenlabs import generate, save 4 | 5 | from utils import settings 6 | 7 | voices = [ 8 | "Adam", 9 | "Antoni", 10 | "Arnold", 11 | "Bella", 12 | "Domi", 13 | "Elli", 14 | "Josh", 15 | "Rachel", 16 | "Sam", 17 | ] 18 | 19 | 20 | class elevenlabs: 21 | def __init__(self): 22 | self.max_chars = 2500 23 | self.voices = voices 24 | 25 | def run(self, text, filepath, random_voice: bool = False): 26 | if random_voice: 27 | voice = self.randomvoice() 28 | else: 29 | voice = str(settings.config["settings"]["tts"]["elevenlabs_voice_name"]).capitalize() 30 | 31 | if settings.config["settings"]["tts"]["elevenlabs_api_key"]: 32 | api_key = settings.config["settings"]["tts"]["elevenlabs_api_key"] 33 | else: 34 | raise ValueError( 35 | "You didn't set an Elevenlabs API key! Please set the config variable ELEVENLABS_API_KEY to a valid API key." 36 | ) 37 | 38 | audio = generate(api_key=api_key, text=text, voice=voice, model="eleven_multilingual_v1") 39 | save(audio=audio, filename=filepath) 40 | 41 | def randomvoice(self): 42 | return random.choice(self.voices) 43 | -------------------------------------------------------------------------------- /TTS/engine_wrapper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from pathlib import Path 4 | from typing import Tuple 5 | 6 | import numpy as np 7 | import translators 8 | from moviepy.audio.AudioClip import AudioClip 9 | from moviepy.audio.fx.volumex import volumex 10 | from moviepy.editor import AudioFileClip 11 | from rich.progress import track 12 | 13 | from utils import settings 14 | from utils.console import print_step, print_substep 15 | from utils.voice import sanitize_text 16 | 17 | DEFAULT_MAX_LENGTH: int = ( 18 | 50 # Video length variable, edit this on your own risk. It should work, but it's not supported 19 | ) 20 | 21 | 22 | class TTSEngine: 23 | """Calls the given TTS engine to reduce code duplication and allow multiple TTS engines. 24 | 25 | Args: 26 | tts_module : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method. 27 | reddit_object : The reddit object that contains the posts to read. 28 | path (Optional) : The unix style path to save the mp3 files to. This must not have leading or trailing slashes. 29 | max_length (Optional) : The maximum length of the mp3 files in total. 30 | 31 | Notes: 32 | tts_module must take the arguments text and filepath. 33 | """ 34 | 35 | def __init__( 36 | self, 37 | tts_module, 38 | reddit_object: dict, 39 | path: str = "assets/temp/", 40 | max_length: int = DEFAULT_MAX_LENGTH, 41 | last_clip_length: int = 0, 42 | ): 43 | self.tts_module = tts_module() 44 | self.reddit_object = reddit_object 45 | 46 | self.redditid = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"]) 47 | self.path = path + self.redditid + "/mp3" 48 | self.max_length = max_length 49 | self.length = 0 50 | self.last_clip_length = last_clip_length 51 | 52 | def add_periods( 53 | self, 54 | ): # adds periods to the end of paragraphs (where people often forget to put them) so tts doesn't blend sentences 55 | for comment in self.reddit_object["comments"]: 56 | # remove links 57 | regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*" 58 | comment["comment_body"] = re.sub(regex_urls, " ", comment["comment_body"]) 59 | comment["comment_body"] = comment["comment_body"].replace("\n", ". ") 60 | comment["comment_body"] = re.sub(r"\bAI\b", "A.I", comment["comment_body"]) 61 | comment["comment_body"] = re.sub(r"\bAGI\b", "A.G.I", comment["comment_body"]) 62 | if comment["comment_body"][-1] != ".": 63 | comment["comment_body"] += "." 64 | comment["comment_body"] = comment["comment_body"].replace(". . .", ".") 65 | comment["comment_body"] = comment["comment_body"].replace(".. . ", ".") 66 | comment["comment_body"] = comment["comment_body"].replace(". . ", ".") 67 | comment["comment_body"] = re.sub(r'\."\.', '".', comment["comment_body"]) 68 | 69 | def run(self) -> Tuple[int, int]: 70 | Path(self.path).mkdir(parents=True, exist_ok=True) 71 | print_step("Saving Text to MP3 files...") 72 | 73 | self.add_periods() 74 | self.call_tts("title", process_text(self.reddit_object["thread_title"])) 75 | # processed_text = ##self.reddit_object["thread_post"] != "" 76 | idx = 0 77 | 78 | if settings.config["settings"]["storymode"]: 79 | if settings.config["settings"]["storymodemethod"] == 0: 80 | if len(self.reddit_object["thread_post"]) > self.tts_module.max_chars: 81 | self.split_post(self.reddit_object["thread_post"], "postaudio") 82 | else: 83 | self.call_tts("postaudio", process_text(self.reddit_object["thread_post"])) 84 | elif settings.config["settings"]["storymodemethod"] == 1: 85 | for idx, text in track(enumerate(self.reddit_object["thread_post"])): 86 | self.call_tts(f"postaudio-{idx}", process_text(text)) 87 | 88 | else: 89 | for idx, comment in track(enumerate(self.reddit_object["comments"]), "Saving..."): 90 | # ! Stop creating mp3 files if the length is greater than max length. 91 | if self.length > self.max_length and idx > 1: 92 | self.length -= self.last_clip_length 93 | idx -= 1 94 | break 95 | if ( 96 | len(comment["comment_body"]) > self.tts_module.max_chars 97 | ): # Split the comment if it is too long 98 | self.split_post(comment["comment_body"], idx) # Split the comment 99 | else: # If the comment is not too long, just call the tts engine 100 | self.call_tts(f"{idx}", process_text(comment["comment_body"])) 101 | 102 | print_substep("Saved Text to MP3 files successfully.", style="bold green") 103 | return self.length, idx 104 | 105 | def split_post(self, text: str, idx): 106 | split_files = [] 107 | split_text = [ 108 | x.group().strip() 109 | for x in re.finditer( 110 | r" *(((.|\n){0," + str(self.tts_module.max_chars) + "})(\.|.$))", text 111 | ) 112 | ] 113 | self.create_silence_mp3() 114 | 115 | idy = None 116 | for idy, text_cut in enumerate(split_text): 117 | newtext = process_text(text_cut) 118 | # print(f"{idx}-{idy}: {newtext}\n") 119 | 120 | if not newtext or newtext.isspace(): 121 | print("newtext was blank because sanitized split text resulted in none") 122 | continue 123 | else: 124 | self.call_tts(f"{idx}-{idy}.part", newtext) 125 | with open(f"{self.path}/list.txt", "w") as f: 126 | for idz in range(0, len(split_text)): 127 | f.write("file " + f"'{idx}-{idz}.part.mp3'" + "\n") 128 | split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3")) 129 | f.write("file " + f"'silence.mp3'" + "\n") 130 | 131 | os.system( 132 | "ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 " 133 | + "-i " 134 | + f"{self.path}/list.txt " 135 | + "-c copy " 136 | + f"{self.path}/{idx}.mp3" 137 | ) 138 | try: 139 | for i in range(0, len(split_files)): 140 | os.unlink(split_files[i]) 141 | except FileNotFoundError as e: 142 | print("File not found: " + e.filename) 143 | except OSError: 144 | print("OSError") 145 | 146 | def call_tts(self, filename: str, text: str): 147 | self.tts_module.run( 148 | text, 149 | filepath=f"{self.path}/{filename}.mp3", 150 | random_voice=settings.config["settings"]["tts"]["random_voice"], 151 | ) 152 | # try: 153 | # self.length += MP3(f"{self.path}/{filename}.mp3").info.length 154 | # except (MutagenError, HeaderNotFoundError): 155 | # self.length += sox.file_info.duration(f"{self.path}/{filename}.mp3") 156 | try: 157 | clip = AudioFileClip(f"{self.path}/{filename}.mp3") 158 | self.last_clip_length = clip.duration 159 | self.length += clip.duration 160 | clip.close() 161 | except: 162 | self.length = 0 163 | 164 | def create_silence_mp3(self): 165 | silence_duration = settings.config["settings"]["tts"]["silence_duration"] 166 | silence = AudioClip( 167 | make_frame=lambda t: np.sin(440 * 2 * np.pi * t), 168 | duration=silence_duration, 169 | fps=44100, 170 | ) 171 | silence = volumex(silence, 0) 172 | silence.write_audiofile(f"{self.path}/silence.mp3", fps=44100, verbose=False, logger=None) 173 | 174 | 175 | def process_text(text: str, clean: bool = True): 176 | lang = settings.config["reddit"]["thread"]["post_lang"] 177 | new_text = sanitize_text(text) if clean else text 178 | if lang: 179 | print_substep("Translating Text...") 180 | translated_text = translators.translate_text(text, translator="google", to_language=lang) 181 | new_text = sanitize_text(translated_text) 182 | return new_text 183 | -------------------------------------------------------------------------------- /TTS/pyttsx.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pyttsx3 4 | 5 | from utils import settings 6 | 7 | 8 | class pyttsx: 9 | def __init__(self): 10 | self.max_chars = 5000 11 | self.voices = [] 12 | 13 | def run( 14 | self, 15 | text: str, 16 | filepath: str, 17 | random_voice=False, 18 | ): 19 | voice_id = settings.config["settings"]["tts"]["python_voice"] 20 | voice_num = settings.config["settings"]["tts"]["py_voice_num"] 21 | if voice_id == "" or voice_num == "": 22 | voice_id = 2 23 | voice_num = 3 24 | raise ValueError("set pyttsx values to a valid value, switching to defaults") 25 | else: 26 | voice_id = int(voice_id) 27 | voice_num = int(voice_num) 28 | for i in range(voice_num): 29 | self.voices.append(i) 30 | i = +1 31 | if random_voice: 32 | voice_id = self.randomvoice() 33 | engine = pyttsx3.init() 34 | voices = engine.getProperty("voices") 35 | engine.setProperty( 36 | "voice", voices[voice_id].id 37 | ) # changing index changes voices but ony 0 and 1 are working here 38 | engine.save_to_file(text, f"{filepath}") 39 | engine.runAndWait() 40 | 41 | def randomvoice(self): 42 | return random.choice(self.voices) 43 | -------------------------------------------------------------------------------- /TTS/streamlabs_polly.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import requests 4 | from requests.exceptions import JSONDecodeError 5 | 6 | from utils import settings 7 | from utils.voice import check_ratelimit 8 | 9 | voices = [ 10 | "Brian", 11 | "Emma", 12 | "Russell", 13 | "Joey", 14 | "Matthew", 15 | "Joanna", 16 | "Kimberly", 17 | "Amy", 18 | "Geraint", 19 | "Nicole", 20 | "Justin", 21 | "Ivy", 22 | "Kendra", 23 | "Salli", 24 | "Raveena", 25 | ] 26 | 27 | 28 | # valid voices https://lazypy.ro/tts/ 29 | 30 | 31 | class StreamlabsPolly: 32 | def __init__(self): 33 | self.url = "https://streamlabs.com/polly/speak" 34 | self.max_chars = 550 35 | self.voices = voices 36 | 37 | def run(self, text, filepath, random_voice: bool = False): 38 | if random_voice: 39 | voice = self.randomvoice() 40 | else: 41 | if not settings.config["settings"]["tts"]["streamlabs_polly_voice"]: 42 | raise ValueError( 43 | f"Please set the config variable STREAMLABS_POLLY_VOICE to a valid voice. options are: {voices}" 44 | ) 45 | voice = str(settings.config["settings"]["tts"]["streamlabs_polly_voice"]).capitalize() 46 | 47 | body = {"voice": voice, "text": text, "service": "polly"} 48 | headers = {"Referer": "https://streamlabs.com/"} 49 | response = requests.post(self.url, headers=headers, data=body) 50 | 51 | if not check_ratelimit(response): 52 | self.run(text, filepath, random_voice) 53 | 54 | else: 55 | try: 56 | voice_data = requests.get(response.json()["speak_url"]) 57 | with open(filepath, "wb") as f: 58 | f.write(voice_data.content) 59 | except (KeyError, JSONDecodeError): 60 | try: 61 | if response.json()["error"] == "No text specified!": 62 | raise ValueError("Please specify a text to convert to speech.") 63 | except (KeyError, JSONDecodeError): 64 | print("Error occurred calling Streamlabs Polly") 65 | 66 | def randomvoice(self): 67 | return random.choice(self.voices) 68 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker build -t rvmt . 3 | -------------------------------------------------------------------------------- /fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # If the install fails, then print an error and exit. 4 | function install_fail() { 5 | echo "Installation failed" 6 | exit 1 7 | } 8 | 9 | # This is the help fuction. It helps users withe the options 10 | function Help(){ 11 | echo "Usage: install.sh [option]" 12 | echo "Options:" 13 | echo " -h: Show this help message and exit" 14 | echo " -d: Install only dependencies" 15 | echo " -p: Install only python dependencies (including playwright)" 16 | echo " -b: Install just the bot" 17 | echo " -l: Install the bot and the python dependencies" 18 | } 19 | 20 | # Options 21 | while getopts ":hydpbl" option; do 22 | case $option in 23 | # -h, prints help message 24 | h) 25 | Help exit 0;; 26 | # -y, assumes yes 27 | y) 28 | ASSUME_YES=1;; 29 | # -d install only dependencies 30 | d) 31 | DEPS_ONLY=1;; 32 | # -p install only python dependencies 33 | p) 34 | PYTHON_ONLY=1;; 35 | b) 36 | JUST_BOT=1;; 37 | l) 38 | BOT_AND_PYTHON=1;; 39 | # if a bad argument is given, then throw an error 40 | \?) 41 | echo "Invalid option: -$OPTARG" >&2 Help exit 1;; 42 | :) 43 | echo "Option -$OPTARG requires an argument." >&2 Help exit 1;; 44 | esac 45 | done 46 | 47 | # Install dependencies for MacOS 48 | function install_macos(){ 49 | # Check if homebrew is installed 50 | if [ ! command -v brew &> /dev/null ]; then 51 | echo "Installing Homebrew" 52 | # if it's is not installed, then install it in a NONINTERACTIVE way 53 | NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" 54 | # Check for what arcitecture, so you can place path. 55 | if [[ "uname -m" == "x86_64" ]]; then 56 | echo "export PATH=/usr/local/bin:$PATH" >> ~/.bash_profile && source ~/.bash_profile 57 | fi 58 | # If not 59 | else 60 | # Print that it's already installed 61 | echo "Homebrew is already installed" 62 | fi 63 | # Install the required packages 64 | echo "Installing required Packages" 65 | if [! command --version python3 &> /dev/null ]; then 66 | echo "Installing python3" 67 | brew install python@3.10 68 | else 69 | echo "python3 already installed." 70 | fi 71 | brew install tcl-tk python-tk 72 | } 73 | 74 | # Function to install for arch (and other forks like manjaro) 75 | function install_arch(){ 76 | echo "Installing required packages" 77 | sudo pacman -S --needed python3 tk git && python3 -m ensurepip unzip || install_fail 78 | } 79 | 80 | # Function to install for debian (and ubuntu) 81 | function install_deb(){ 82 | echo "Installing required packages" 83 | sudo apt install python3 python3-dev python3-tk python3-pip unzip || install_fail 84 | } 85 | 86 | # Function to install for fedora (and other forks) 87 | function install_fedora(){ 88 | echo "Installing required packages" 89 | sudo dnf install python3 python3-tkinter python3-pip python3-devel unzip || install_fail 90 | } 91 | 92 | # Function to install for centos (and other forks based on it) 93 | function install_centos(){ 94 | echo "Installing required packages" 95 | sudo yum install -y python3 || install_fail 96 | sudo yum install -y python3-tkinter epel-release python3-pip unzip|| install_fail 97 | } 98 | 99 | function get_the_bot(){ 100 | echo "Downloading the bot" 101 | rm -rf RedditVideoMakerBot-master 102 | curl -sL https://github.com/elebumm/RedditVideoMakerBot/archive/refs/heads/master.zip -o master.zip 103 | unzip master.zip 104 | rm -rf master.zip 105 | } 106 | 107 | #install python dependencies 108 | function install_python_dep(){ 109 | # tell the user that the script is going to install the python dependencies 110 | echo "Installing python dependencies" 111 | # cd into the directory 112 | cd RedditVideoMakerBot-master 113 | # install the dependencies 114 | pip3 install -r requirements.txt 115 | # cd out 116 | cd .. 117 | } 118 | 119 | # install playwright function 120 | function install_playwright(){ 121 | # tell the user that the script is going to install playwright 122 | echo "Installing playwright" 123 | # cd into the directory where the script is downloaded 124 | cd RedditVideoMakerBot-master 125 | # run the install script 126 | python3 -m playwright install 127 | python3 -m playwright install-deps 128 | # give a note 129 | printf "Note, if these gave any errors, playwright may not be officially supported on your OS, check this issues page for support\nhttps://github.com/microsoft/playwright/issues" 130 | if [ -x "$(command -v pacman)" ]; then 131 | printf "It seems you are on and Arch based distro.\nTry installing these from the AUR for playwright to run:\nenchant1.6\nicu66\nlibwebp052\n" 132 | fi 133 | cd .. 134 | } 135 | 136 | # Install depndencies 137 | function install_deps(){ 138 | # if the platform is mac, install macos 139 | if [ "$(uname)" == "Darwin" ]; then 140 | install_macos || install_fail 141 | # if pacman is found 142 | elif [ -x "$(command -v pacman)" ]; then 143 | # install for arch 144 | install_arch || install_fail 145 | # if apt-get is found 146 | elif [ -x "$(command -v apt-get)" ]; then 147 | # install fro debian 148 | install_deb || install_fail 149 | # if dnf is found 150 | elif [ -x "$(command -v dnf)" ]; then 151 | # install for fedora 152 | install_fedora || install_fail 153 | # if yum is found 154 | elif [ -x "$(command -v yum)" ]; then 155 | # install for centos 156 | install_centos || install_fail 157 | # else 158 | else 159 | # print an error message and exit 160 | printf "Your OS is not supported\n Please install python3, pip3 and git manually\n After that, run the script again with the -pb option to install python and playwright dependencies\n If you want to add support for your OS, please open a pull request on github\n 161 | https://github.com/elebumm/RedditVideoMakerBot" 162 | exit 1 163 | fi 164 | } 165 | 166 | # Main function 167 | function install_main(){ 168 | # Print that are installing 169 | echo "Installing..." 170 | # if -y (assume yes) continue 171 | if [[ ASSUME_YES -eq 1 ]]; then 172 | echo "Assuming yes" 173 | # else, ask if they want to continue 174 | else 175 | echo "Continue? (y/n)" 176 | read answer 177 | # if the answer is not yes, then exit 178 | if [ "$answer" != "y" ]; then 179 | echo "Aborting" 180 | exit 1 181 | fi 182 | fi 183 | # if the -d (only dependencies) options is selected install just the dependencies 184 | if [[ DEPS_ONLY -eq 1 ]]; then 185 | echo "Installing only dependencies" 186 | install_deps 187 | elif [[ PYTHON_ONLY -eq 1 ]]; then 188 | # if the -p (only python dependencies) options is selected install just the python dependencies and playwright 189 | echo "Installing only python dependencies" 190 | install_python_dep 191 | install_playwright 192 | # if the -b (only the bot) options is selected install just the bot 193 | elif [[ JUST_BOT -eq 1 ]]; then 194 | echo "Installing only the bot" 195 | get_the_bot 196 | # if the -l (bot and python) options is selected install just the bot and python dependencies 197 | elif [[ BOT_AND_PYTHON -eq 1 ]]; then 198 | echo "Installing only the bot and python dependencies" 199 | get_the_bot 200 | install_python_dep 201 | # else, install everything 202 | else 203 | echo "Installing all" 204 | install_deps 205 | get_the_bot 206 | install_python_dep 207 | install_playwright 208 | fi 209 | 210 | DIR="./RedditVideoMakerBot-master" 211 | if [ -d "$DIR" ]; then 212 | printf "\nThe bot is installed, want to run it?" 213 | # if -y (assume yes) continue 214 | if [[ ASSUME_YES -eq 1 ]]; then 215 | echo "Assuming yes" 216 | # else, ask if they want to continue 217 | else 218 | echo "Continue? (y/n)" 219 | read answer 220 | # if the answer is not yes, then exit 221 | if [ "$answer" != "y" ]; then 222 | echo "Aborting" 223 | exit 1 224 | fi 225 | fi 226 | cd RedditVideoMakerBot-master 227 | python3 main.py 228 | fi 229 | } 230 | 231 | # Run the main function 232 | install_main 233 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import math 3 | import sys 4 | from os import name 5 | from pathlib import Path 6 | from subprocess import Popen 7 | from typing import NoReturn 8 | 9 | from prawcore import ResponseException 10 | 11 | from reddit.subreddit import get_subreddit_threads 12 | from utils import settings 13 | from utils.cleanup import cleanup 14 | from utils.console import print_markdown, print_step 15 | from utils.console import print_substep 16 | from utils.ffmpeg_install import ffmpeg_install 17 | from utils.id import id 18 | from utils.version import checkversion 19 | from video_creation.background import ( 20 | download_background_video, 21 | download_background_audio, 22 | chop_background, 23 | get_background_config, 24 | ) 25 | from video_creation.final_video import make_final_video 26 | from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts 27 | from video_creation.voices import save_text_to_mp3 28 | 29 | __VERSION__ = "3.2.1" 30 | 31 | print( 32 | """ 33 | ██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ 34 | ██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ 35 | ██████╔╝█████╗ ██║ ██║██║ ██║██║ ██║ ██║ ██║██║██║ ██║█████╗ ██║ ██║ ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ 36 | ██╔══██╗██╔══╝ ██║ ██║██║ ██║██║ ██║ ╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ 37 | ██║ ██║███████╗██████╔╝██████╔╝██║ ██║ ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝ ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ 38 | ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ 39 | """ 40 | ) 41 | # Modified by JasonLovesDoggo 42 | print_markdown( 43 | "### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/" 44 | ) 45 | checkversion(__VERSION__) 46 | 47 | 48 | def main(POST_ID=None) -> None: 49 | global redditid, reddit_object 50 | reddit_object = get_subreddit_threads(POST_ID) 51 | redditid = id(reddit_object) 52 | length, number_of_comments = save_text_to_mp3(reddit_object) 53 | length = math.ceil(length) 54 | get_screenshots_of_reddit_posts(reddit_object, number_of_comments) 55 | bg_config = { 56 | "video": get_background_config("video"), 57 | "audio": get_background_config("audio"), 58 | } 59 | download_background_video(bg_config["video"]) 60 | download_background_audio(bg_config["audio"]) 61 | chop_background(bg_config, length, reddit_object) 62 | make_final_video(number_of_comments, length, reddit_object, bg_config) 63 | 64 | 65 | def run_many(times) -> None: 66 | for x in range(1, times + 1): 67 | print_step( 68 | f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}' 69 | ) # correct 1st 2nd 3rd 4th 5th.... 70 | main() 71 | Popen("cls" if name == "nt" else "clear", shell=True).wait() 72 | 73 | 74 | def shutdown() -> NoReturn: 75 | if "redditid" in globals(): 76 | print_markdown("## Clearing temp files") 77 | cleanup(redditid) 78 | 79 | print("Exiting...") 80 | sys.exit() 81 | 82 | 83 | if __name__ == "__main__": 84 | if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11]: 85 | print( 86 | "Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again." 87 | ) 88 | sys.exit() 89 | ffmpeg_install() 90 | directory = Path().absolute() 91 | config = settings.check_toml( 92 | f"{directory}/utils/.config.template.toml", f"{directory}/config.toml" 93 | ) 94 | config is False and sys.exit() 95 | 96 | if ( 97 | not settings.config["settings"]["tts"]["tiktok_sessionid"] 98 | or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" 99 | ) and config["settings"]["tts"]["voice_choice"] == "tiktok": 100 | print_substep( 101 | "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", 102 | "bold red", 103 | ) 104 | sys.exit() 105 | try: 106 | if config["reddit"]["thread"]["post_id"]: 107 | for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): 108 | index += 1 109 | print_step( 110 | f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}' 111 | ) 112 | main(post_id) 113 | Popen("cls" if name == "nt" else "clear", shell=True).wait() 114 | elif config["settings"]["times_to_run"]: 115 | run_many(config["settings"]["times_to_run"]) 116 | else: 117 | main() 118 | except KeyboardInterrupt: 119 | shutdown() 120 | except ResponseException: 121 | print_markdown("## Invalid credentials") 122 | print_markdown("Please check your credentials in the config.toml file") 123 | shutdown() 124 | except Exception as err: 125 | config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" 126 | config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" 127 | print_step( 128 | f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n" 129 | f"Version: {__VERSION__} \n" 130 | f"Error: {err} \n" 131 | f'Config: {config["settings"]}' 132 | ) 133 | raise err 134 | -------------------------------------------------------------------------------- /ptt.py: -------------------------------------------------------------------------------- 1 | import pyttsx3 2 | 3 | engine = pyttsx3.init() 4 | voices = engine.getProperty("voices") 5 | for voice in voices: 6 | print(voice, voice.id) 7 | engine.setProperty("voice", voice.id) 8 | engine.say("Hello World!") 9 | engine.runAndWait() 10 | engine.stop() 11 | -------------------------------------------------------------------------------- /reddit/subreddit.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import praw 4 | from praw.models import MoreComments 5 | from prawcore.exceptions import ResponseException 6 | 7 | from utils import settings 8 | from utils.ai_methods import sort_by_similarity 9 | from utils.console import print_step, print_substep 10 | from utils.posttextparser import posttextparser 11 | from utils.subreddit import get_subreddit_undone 12 | from utils.videos import check_done 13 | from utils.voice import sanitize_text 14 | 15 | 16 | def get_subreddit_threads(POST_ID: str): 17 | """ 18 | Returns a list of threads from the AskReddit subreddit. 19 | """ 20 | 21 | print_substep("Logging into Reddit.") 22 | 23 | content = {} 24 | if settings.config["reddit"]["creds"]["2fa"]: 25 | print("\nEnter your two-factor authentication code from your authenticator app.\n") 26 | code = input("> ") 27 | print() 28 | pw = settings.config["reddit"]["creds"]["password"] 29 | passkey = f"{pw}:{code}" 30 | else: 31 | passkey = settings.config["reddit"]["creds"]["password"] 32 | username = settings.config["reddit"]["creds"]["username"] 33 | if str(username).casefold().startswith("u/"): 34 | username = username[2:] 35 | try: 36 | reddit = praw.Reddit( 37 | client_id=settings.config["reddit"]["creds"]["client_id"], 38 | client_secret=settings.config["reddit"]["creds"]["client_secret"], 39 | user_agent="Accessing Reddit threads", 40 | username=username, 41 | passkey=passkey, 42 | check_for_async=False, 43 | ) 44 | except ResponseException as e: 45 | if e.response.status_code == 401: 46 | print("Invalid credentials - please check them in config.toml") 47 | except: 48 | print("Something went wrong...") 49 | 50 | # Ask user for subreddit input 51 | print_step("Getting subreddit threads...") 52 | similarity_score = 0 53 | if not settings.config["reddit"]["thread"][ 54 | "subreddit" 55 | ]: # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython") 56 | try: 57 | subreddit = reddit.subreddit( 58 | re.sub(r"r\/", "", input("What subreddit would you like to pull from? ")) 59 | # removes the r/ from the input 60 | ) 61 | except ValueError: 62 | subreddit = reddit.subreddit("askreddit") 63 | print_substep("Subreddit not defined. Using AskReddit.") 64 | else: 65 | sub = settings.config["reddit"]["thread"]["subreddit"] 66 | print_substep(f"Using subreddit: r/{sub} from TOML config") 67 | subreddit_choice = sub 68 | if str(subreddit_choice).casefold().startswith("r/"): # removes the r/ from the input 69 | subreddit_choice = subreddit_choice[2:] 70 | subreddit = reddit.subreddit(subreddit_choice) 71 | 72 | if POST_ID: # would only be called if there are multiple queued posts 73 | submission = reddit.submission(id=POST_ID) 74 | 75 | elif ( 76 | settings.config["reddit"]["thread"]["post_id"] 77 | and len(str(settings.config["reddit"]["thread"]["post_id"]).split("+")) == 1 78 | ): 79 | submission = reddit.submission(id=settings.config["reddit"]["thread"]["post_id"]) 80 | elif settings.config["ai"]["ai_similarity_enabled"]: # ai sorting based on comparison 81 | threads = subreddit.hot(limit=50) 82 | keywords = settings.config["ai"]["ai_similarity_keywords"].split(",") 83 | keywords = [keyword.strip() for keyword in keywords] 84 | # Reformat the keywords for printing 85 | keywords_print = ", ".join(keywords) 86 | print(f"Sorting threads by similarity to the given keywords: {keywords_print}") 87 | threads, similarity_scores = sort_by_similarity(threads, keywords) 88 | submission, similarity_score = get_subreddit_undone( 89 | threads, subreddit, similarity_scores=similarity_scores 90 | ) 91 | else: 92 | threads = subreddit.hot(limit=25) 93 | submission = get_subreddit_undone(threads, subreddit) 94 | 95 | if submission is None: 96 | return get_subreddit_threads(POST_ID) # submission already done. rerun 97 | 98 | elif not submission.num_comments and settings.config["settings"]["storymode"] == "false": 99 | print_substep("No comments found. Skipping.") 100 | exit() 101 | 102 | submission = check_done(submission) # double-checking 103 | 104 | upvotes = submission.score 105 | ratio = submission.upvote_ratio * 100 106 | num_comments = submission.num_comments 107 | threadurl = f"https://new.reddit.com/{submission.permalink}" 108 | 109 | print_substep(f"Video will be: {submission.title} :thumbsup:", style="bold green") 110 | print_substep(f"Thread url is: {threadurl} :thumbsup:", style="bold green") 111 | print_substep(f"Thread has {upvotes} upvotes", style="bold blue") 112 | print_substep(f"Thread has a upvote ratio of {ratio}%", style="bold blue") 113 | print_substep(f"Thread has {num_comments} comments", style="bold blue") 114 | if similarity_score: 115 | print_substep( 116 | f"Thread has a similarity score up to {round(similarity_score * 100)}%", 117 | style="bold blue", 118 | ) 119 | 120 | content["thread_url"] = threadurl 121 | content["thread_title"] = submission.title 122 | content["thread_id"] = submission.id 123 | content["is_nsfw"] = submission.over_18 124 | content["comments"] = [] 125 | if settings.config["settings"]["storymode"]: 126 | if settings.config["settings"]["storymodemethod"] == 1: 127 | content["thread_post"] = posttextparser(submission.selftext) 128 | else: 129 | content["thread_post"] = submission.selftext 130 | else: 131 | for top_level_comment in submission.comments: 132 | if isinstance(top_level_comment, MoreComments): 133 | continue 134 | 135 | if top_level_comment.body in ["[removed]", "[deleted]"]: 136 | continue # # see https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/78 137 | if not top_level_comment.stickied: 138 | sanitised = sanitize_text(top_level_comment.body) 139 | if not sanitised or sanitised == " ": 140 | continue 141 | if len(top_level_comment.body) <= int( 142 | settings.config["reddit"]["thread"]["max_comment_length"] 143 | ): 144 | if len(top_level_comment.body) >= int( 145 | settings.config["reddit"]["thread"]["min_comment_length"] 146 | ): 147 | if ( 148 | top_level_comment.author is not None 149 | and sanitize_text(top_level_comment.body) is not None 150 | ): # if errors occur with this change to if not. 151 | content["comments"].append( 152 | { 153 | "comment_body": top_level_comment.body, 154 | "comment_url": top_level_comment.permalink, 155 | "comment_id": top_level_comment.id, 156 | } 157 | ) 158 | 159 | print_substep("Received subreddit threads Successfully.", style="bold green") 160 | return content 161 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.26.142 2 | botocore==1.29.142 3 | gTTS==2.5.1 4 | moviepy==1.0.3 5 | playwright==1.34.0 6 | praw==7.7.0 7 | prawcore~=2.3.0 8 | requests==2.31.0 9 | rich==13.4.1 10 | toml==0.10.2 11 | translators==5.7.6 12 | pyttsx3==2.90 13 | Pillow==10.2.0 14 | tomlkit==0.11.8 15 | Flask==2.3.3 16 | clean-text==0.6.0 17 | unidecode==1.3.8 18 | spacy==3.5.3 19 | torch==2.2.0 20 | transformers==4.37.2 21 | ffmpeg-python==0.2.0 22 | elevenlabs==0.2.17 23 | yt-dlp==2023.7.6 -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set VENV_DIR=.venv 3 | 4 | if exist "%VENV_DIR%" ( 5 | echo Activating virtual environment... 6 | call "%VENV_DIR%\Scripts\activate.bat" 7 | ) 8 | 9 | echo Running Python script... 10 | python main.py 11 | 12 | if errorlevel 1 ( 13 | echo An error occurred. Press any key to exit. 14 | pause >nul 15 | ) 16 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker run -v $(pwd)/out/:/app/assets -v $(pwd)/.env:/app/.env -it rvmt 3 | -------------------------------------------------------------------------------- /utils/.config.template.toml: -------------------------------------------------------------------------------- 1 | [reddit.creds] 2 | client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." } 3 | client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." } 4 | username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } 5 | password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } 6 | 2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } 7 | 8 | 9 | [reddit.thread] 10 | random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } 11 | subreddit = { optional = false, regex = "[_0-9a-zA-Z\\+]+$", nmin = 3, explanation = "What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.", example = "AskReddit+Redditdev", oob_error = "A subreddit name HAS to be between 3 and 20 characters" } 12 | post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" } 13 | max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "max number of characters a comment can have. default is 500", example = 500, oob_error = "the max comment length should be between 10 and 10000" } 14 | min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "min_comment_length number of characters a comment can have. default is 0", example = 50, oob_error = "the max comment length should be between 1 and 100" } 15 | post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] } 16 | min_comments = { default = 20, optional = false, nmin = 10, type = "int", explanation = "The minimum number of comments a post should have to be included. default is 20", example = 29, oob_error = "the minimum number of comments should be between 15 and 999999" } 17 | 18 | [ai] 19 | ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"} 20 | ai_similarity_keywords = {optional = true, type="str", example= 'Elon Musk, Twitter, Stocks', explanation = "Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity"} 21 | 22 | [settings] 23 | allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" } 24 | theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." } 25 | times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." } 26 | opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Sets the opacity of the comments when overlayed over the background", type = "float", nmin = 0, nmax = 1, oob_error = "The opacity HAS to be between 0 and 1", input_error = "The opacity HAS to be a decimal number between 0 and 1" } 27 | #transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" } 28 | storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, great for subreddits with stories" } 29 | storymodemethod= { optional = true, default = 1, example = 1, explanation = "Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video ", type = "int", nmin = 0, oob_error = "It's very hard to run something less than once.", options = [0, 1] } 30 | storymode_max_length = { optional = true, default = 1000, example = 1000, explanation = "Max length of the storymode video in characters. 200 characters are approximately 50 seconds.", type = "int", nmin = 1, oob_error = "It's very hard to make a video under a second." } 31 | resolution_w = { optional = false, default = 1080, example = 1440, explantation = "Sets the width in pixels of the final video" } 32 | resolution_h = { optional = false, default = 1920, example = 2560, explantation = "Sets the height in pixels of the final video" } 33 | zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" } 34 | 35 | [settings.background] 36 | background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" } 37 | background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" } 38 | background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"} 39 | enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"} 40 | background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)" } 41 | background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font family for the thumbnail text" } 42 | background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Font size in pixels for the thumbnail text" } 43 | background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" } 44 | 45 | [settings.tts] 46 | voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", ], example = "tiktok", explanation = "The voice platform used for TTS generation. " } 47 | random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" } 48 | elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] } 49 | elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" } 50 | aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } 51 | streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } 52 | tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" } 53 | tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." } 54 | python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" } 55 | py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" } 56 | silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" } 57 | no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" } 58 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/utils/__init__.py -------------------------------------------------------------------------------- /utils/ai_methods.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import torch 3 | from transformers import AutoTokenizer, AutoModel 4 | 5 | 6 | # Mean Pooling - Take attention mask into account for correct averaging 7 | def mean_pooling(model_output, attention_mask): 8 | token_embeddings = model_output[0] # First element of model_output contains all token embeddings 9 | input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() 10 | return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp( 11 | input_mask_expanded.sum(1), min=1e-9 12 | ) 13 | 14 | 15 | # This function sort the given threads based on their total similarity with the given keywords 16 | def sort_by_similarity(thread_objects, keywords): 17 | # Initialize tokenizer + model. 18 | tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2") 19 | model = AutoModel.from_pretrained("sentence-transformers/all-MiniLM-L6-v2") 20 | 21 | # Transform the generator to a list of Submission Objects, so we can sort later based on context similarity to 22 | # keywords 23 | thread_objects = list(thread_objects) 24 | 25 | threads_sentences = [] 26 | for i, thread in enumerate(thread_objects): 27 | threads_sentences.append(" ".join([thread.title, thread.selftext])) 28 | 29 | # Threads inference 30 | encoded_threads = tokenizer( 31 | threads_sentences, padding=True, truncation=True, return_tensors="pt" 32 | ) 33 | with torch.no_grad(): 34 | threads_embeddings = model(**encoded_threads) 35 | threads_embeddings = mean_pooling(threads_embeddings, encoded_threads["attention_mask"]) 36 | 37 | # Keywords inference 38 | encoded_keywords = tokenizer(keywords, padding=True, truncation=True, return_tensors="pt") 39 | with torch.no_grad(): 40 | keywords_embeddings = model(**encoded_keywords) 41 | keywords_embeddings = mean_pooling(keywords_embeddings, encoded_keywords["attention_mask"]) 42 | 43 | # Compare every keyword w/ every thread embedding 44 | threads_embeddings_tensor = torch.tensor(threads_embeddings) 45 | total_scores = torch.zeros(threads_embeddings_tensor.shape[0]) 46 | cosine_similarity = torch.nn.CosineSimilarity() 47 | for keyword_embedding in keywords_embeddings: 48 | keyword_embedding = torch.tensor(keyword_embedding).repeat( 49 | threads_embeddings_tensor.shape[0], 1 50 | ) 51 | similarity = cosine_similarity(keyword_embedding, threads_embeddings_tensor) 52 | total_scores += similarity 53 | 54 | similarity_scores, indices = torch.sort(total_scores, descending=True) 55 | 56 | threads_sentences = np.array(threads_sentences)[indices.numpy()] 57 | 58 | thread_objects = np.array(thread_objects)[indices.numpy()].tolist() 59 | 60 | # print('Similarity Thread Ranking') 61 | # for i, thread in enumerate(thread_objects): 62 | # print(f'{i}) {threads_sentences[i]} score {similarity_scores[i]}') 63 | 64 | return thread_objects, similarity_scores 65 | -------------------------------------------------------------------------------- /utils/background_audios.json: -------------------------------------------------------------------------------- 1 | { 2 | "__comment": "Supported Backgrounds Audio. Can add/remove background audio here...", 3 | "lofi": [ 4 | "https://www.youtube.com/watch?v=LTphVIore3A", 5 | "lofi.mp3", 6 | "Super Lofi World" 7 | ], 8 | "lofi-2":[ 9 | "https://www.youtube.com/watch?v=BEXL80LS0-I", 10 | "lofi-2.mp3", 11 | "stompsPlaylist" 12 | ], 13 | "chill-summer":[ 14 | "https://www.youtube.com/watch?v=EZE8JagnBI8", 15 | "chill-summer.mp3", 16 | "Mellow Vibes Radio" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /utils/background_videos.json: -------------------------------------------------------------------------------- 1 | { 2 | "__comment": "Supported Backgrounds. Can add/remove background video here...", 3 | "motor-gta": [ 4 | "https://www.youtube.com/watch?v=vw5L4xCPy9Q", 5 | "bike-parkour-gta.mp4", 6 | "Achy Gaming", 7 | "center" 8 | ], 9 | "rocket-league": [ 10 | "https://www.youtube.com/watch?v=2X9QGY__0II", 11 | "rocket_league.mp4", 12 | "Orbital Gameplay", 13 | "center" 14 | ], 15 | "minecraft": [ 16 | "https://www.youtube.com/watch?v=n_Dv4JMiwK8", 17 | "parkour.mp4", 18 | "bbswitzer", 19 | "center" 20 | ], 21 | "gta": [ 22 | "https://www.youtube.com/watch?v=qGa9kWREOnE", 23 | "gta-stunt-race.mp4", 24 | "Achy Gaming", 25 | "center" 26 | ], 27 | "csgo-surf": [ 28 | "https://www.youtube.com/watch?v=E-8JlyO59Io", 29 | "csgo-surf.mp4", 30 | "Aki", 31 | "center" 32 | ], 33 | "cluster-truck": [ 34 | "https://www.youtube.com/watch?v=uVKxtdMgJVU", 35 | "cluster_truck.mp4", 36 | "No Copyright Gameplay", 37 | "center" 38 | ], 39 | "minecraft-2": [ 40 | "https://www.youtube.com/watch?v=Pt5_GSKIWQM", 41 | "minecraft-2.mp4", 42 | "Itslpsn", 43 | "center" 44 | ], 45 | "multiversus": [ 46 | "https://www.youtube.com/watch?v=66oK1Mktz6g", 47 | "multiversus.mp4", 48 | "MKIceAndFire", 49 | "center" 50 | ], 51 | "fall-guys": [ 52 | "https://www.youtube.com/watch?v=oGSsgACIc6Q", 53 | "fall-guys.mp4", 54 | "Throneful", 55 | "center" 56 | ], 57 | "steep": [ 58 | "https://www.youtube.com/watch?v=EnGiQrWBrko", 59 | "steep.mp4", 60 | "joel", 61 | "center" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /utils/cleanup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from os.path import exists 4 | 5 | 6 | def _listdir(d): # listdir with full path 7 | return [os.path.join(d, f) for f in os.listdir(d)] 8 | 9 | 10 | def cleanup(reddit_id) -> int: 11 | """Deletes all temporary assets in assets/temp 12 | 13 | Returns: 14 | int: How many files were deleted 15 | """ 16 | directory = f"../assets/temp/{reddit_id}/" 17 | if exists(directory): 18 | shutil.rmtree(directory) 19 | 20 | return 1 21 | -------------------------------------------------------------------------------- /utils/console.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from rich.columns import Columns 4 | from rich.console import Console 5 | from rich.markdown import Markdown 6 | from rich.padding import Padding 7 | from rich.panel import Panel 8 | from rich.text import Text 9 | 10 | console = Console() 11 | 12 | 13 | def print_markdown(text) -> None: 14 | """Prints a rich info message. Support Markdown syntax.""" 15 | 16 | md = Padding(Markdown(text), 2) 17 | console.print(md) 18 | 19 | 20 | def print_step(text) -> None: 21 | """Prints a rich info message.""" 22 | 23 | panel = Panel(Text(text, justify="left")) 24 | console.print(panel) 25 | 26 | 27 | def print_table(items) -> None: 28 | """Prints items in a table.""" 29 | 30 | console.print(Columns([Panel(f"[yellow]{item}", expand=True) for item in items])) 31 | 32 | 33 | def print_substep(text, style="") -> None: 34 | """Prints a rich colored info message without the panelling.""" 35 | console.print(text, style=style) 36 | 37 | 38 | def handle_input( 39 | message: str = "", 40 | check_type=False, 41 | match: str = "", 42 | err_message: str = "", 43 | nmin=None, 44 | nmax=None, 45 | oob_error="", 46 | extra_info="", 47 | options: list = None, 48 | default=NotImplemented, 49 | optional=False, 50 | ): 51 | if optional: 52 | console.print(message + "\n[green]This is an optional value. Do you want to skip it? (y/n)") 53 | if input().casefold().startswith("y"): 54 | return default if default is not NotImplemented else "" 55 | if default is not NotImplemented: 56 | console.print( 57 | "[green]" 58 | + message 59 | + '\n[blue bold]The default value is "' 60 | + str(default) 61 | + '"\nDo you want to use it?(y/n)' 62 | ) 63 | if input().casefold().startswith("y"): 64 | return default 65 | if options is None: 66 | match = re.compile(match) 67 | console.print("[green bold]" + extra_info, no_wrap=True) 68 | while True: 69 | console.print(message, end="") 70 | user_input = input("").strip() 71 | if check_type is not False: 72 | try: 73 | user_input = check_type(user_input) 74 | if (nmin is not None and user_input < nmin) or ( 75 | nmax is not None and user_input > nmax 76 | ): 77 | # FAILSTATE Input out of bounds 78 | console.print("[red]" + oob_error) 79 | continue 80 | break # Successful type conversion and number in bounds 81 | except ValueError: 82 | # Type conversion failed 83 | console.print("[red]" + err_message) 84 | continue 85 | elif match != "" and re.match(match, user_input) is None: 86 | console.print("[red]" + err_message + "\nAre you absolutely sure it's correct?(y/n)") 87 | if input().casefold().startswith("y"): 88 | break 89 | continue 90 | else: 91 | # FAILSTATE Input STRING out of bounds 92 | if (nmin is not None and len(user_input) < nmin) or ( 93 | nmax is not None and len(user_input) > nmax 94 | ): 95 | console.print("[red bold]" + oob_error) 96 | continue 97 | break # SUCCESS Input STRING in bounds 98 | return user_input 99 | console.print(extra_info, no_wrap=True) 100 | while True: 101 | console.print(message, end="") 102 | user_input = input("").strip() 103 | if check_type is not False: 104 | try: 105 | isinstance(eval(user_input), check_type) 106 | return check_type(user_input) 107 | except: 108 | console.print( 109 | "[red bold]" 110 | + err_message 111 | + "\nValid options are: " 112 | + ", ".join(map(str, options)) 113 | + "." 114 | ) 115 | continue 116 | if user_input in options: 117 | return user_input 118 | console.print( 119 | "[red bold]" + err_message + "\nValid options are: " + ", ".join(map(str, options)) + "." 120 | ) 121 | -------------------------------------------------------------------------------- /utils/ffmpeg_install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import zipfile 4 | 5 | import requests 6 | 7 | 8 | def ffmpeg_install_windows(): 9 | try: 10 | ffmpeg_url = ( 11 | "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.zip" 12 | ) 13 | ffmpeg_zip_filename = "ffmpeg.zip" 14 | ffmpeg_extracted_folder = "ffmpeg" 15 | 16 | # Check if ffmpeg.zip already exists 17 | if os.path.exists(ffmpeg_zip_filename): 18 | os.remove(ffmpeg_zip_filename) 19 | 20 | # Download FFmpeg 21 | r = requests.get(ffmpeg_url) 22 | with open(ffmpeg_zip_filename, "wb") as f: 23 | f.write(r.content) 24 | 25 | # Check if the extracted folder already exists 26 | if os.path.exists(ffmpeg_extracted_folder): 27 | # Remove existing extracted folder and its contents 28 | for root, dirs, files in os.walk(ffmpeg_extracted_folder, topdown=False): 29 | for file in files: 30 | os.remove(os.path.join(root, file)) 31 | for dir in dirs: 32 | os.rmdir(os.path.join(root, dir)) 33 | os.rmdir(ffmpeg_extracted_folder) 34 | 35 | # Extract FFmpeg 36 | with zipfile.ZipFile(ffmpeg_zip_filename, "r") as zip_ref: 37 | zip_ref.extractall() 38 | os.remove("ffmpeg.zip") 39 | 40 | # Rename and move files 41 | os.rename(f"{ffmpeg_extracted_folder}-6.0-full_build", ffmpeg_extracted_folder) 42 | for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "bin")): 43 | os.rename( 44 | os.path.join(ffmpeg_extracted_folder, "bin", file), 45 | os.path.join(".", file), 46 | ) 47 | os.rmdir(os.path.join(ffmpeg_extracted_folder, "bin")) 48 | for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "doc")): 49 | os.remove(os.path.join(ffmpeg_extracted_folder, "doc", file)) 50 | for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "presets")): 51 | os.remove(os.path.join(ffmpeg_extracted_folder, "presets", file)) 52 | os.rmdir(os.path.join(ffmpeg_extracted_folder, "presets")) 53 | os.rmdir(os.path.join(ffmpeg_extracted_folder, "doc")) 54 | os.remove(os.path.join(ffmpeg_extracted_folder, "LICENSE")) 55 | os.remove(os.path.join(ffmpeg_extracted_folder, "README.txt")) 56 | os.rmdir(ffmpeg_extracted_folder) 57 | 58 | print( 59 | "FFmpeg installed successfully! Please restart your computer and then re-run the program." 60 | ) 61 | except Exception as e: 62 | print( 63 | "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again." 64 | ) 65 | print(e) 66 | exit() 67 | 68 | 69 | def ffmpeg_install_linux(): 70 | try: 71 | subprocess.run( 72 | "sudo apt install ffmpeg", 73 | shell=True, 74 | stdout=subprocess.PIPE, 75 | stderr=subprocess.PIPE, 76 | ) 77 | except Exception as e: 78 | print( 79 | "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again." 80 | ) 81 | print(e) 82 | exit() 83 | print("FFmpeg installed successfully! Please re-run the program.") 84 | exit() 85 | 86 | 87 | def ffmpeg_install_mac(): 88 | try: 89 | subprocess.run( 90 | "brew install ffmpeg", 91 | shell=True, 92 | stdout=subprocess.PIPE, 93 | stderr=subprocess.PIPE, 94 | ) 95 | except FileNotFoundError: 96 | print( 97 | "Homebrew is not installed. Please install it and try again. Otherwise, please install FFmpeg manually and try again." 98 | ) 99 | exit() 100 | print("FFmpeg installed successfully! Please re-run the program.") 101 | exit() 102 | 103 | 104 | def ffmpeg_install(): 105 | try: 106 | # Try to run the FFmpeg command 107 | subprocess.run( 108 | ["ffmpeg", "-version"], 109 | check=True, 110 | stdout=subprocess.PIPE, 111 | stderr=subprocess.PIPE, 112 | ) 113 | except FileNotFoundError as e: 114 | # Check if there's ffmpeg.exe in the current directory 115 | if os.path.exists("./ffmpeg.exe"): 116 | print( 117 | "FFmpeg is installed on this system! If you are seeing this error for the second time, restart your computer." 118 | ) 119 | print("FFmpeg is not installed on this system.") 120 | resp = input( 121 | "We can try to automatically install it for you. Would you like to do that? (y/n): " 122 | ) 123 | if resp.lower() == "y": 124 | print("Installing FFmpeg...") 125 | if os.name == "nt": 126 | ffmpeg_install_windows() 127 | elif os.name == "posix": 128 | ffmpeg_install_linux() 129 | elif os.name == "mac": 130 | ffmpeg_install_mac() 131 | else: 132 | print("Your OS is not supported. Please install FFmpeg manually and try again.") 133 | exit() 134 | else: 135 | print("Please install FFmpeg manually and try again.") 136 | exit() 137 | except Exception as e: 138 | print( 139 | "Welcome fellow traveler! You're one of the few who have made it this far. We have no idea how you got at this error, but we're glad you're here. Please report this error to the developer, and we'll try to fix it as soon as possible. Thank you for your patience!" 140 | ) 141 | print(e) 142 | return None 143 | -------------------------------------------------------------------------------- /utils/gui_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from pathlib import Path 4 | 5 | import toml 6 | import tomlkit 7 | from flask import flash 8 | 9 | 10 | # Get validation checks from template 11 | def get_checks(): 12 | template = toml.load("utils/.config.template.toml") 13 | checks = {} 14 | 15 | def unpack_checks(obj: dict): 16 | for key in obj.keys(): 17 | if "optional" in obj[key].keys(): 18 | checks[key] = obj[key] 19 | else: 20 | unpack_checks(obj[key]) 21 | 22 | unpack_checks(template) 23 | 24 | return checks 25 | 26 | 27 | # Get current config (from config.toml) as dict 28 | def get_config(obj: dict, done={}): 29 | for key in obj.keys(): 30 | if not isinstance(obj[key], dict): 31 | done[key] = obj[key] 32 | else: 33 | get_config(obj[key], done) 34 | 35 | return done 36 | 37 | 38 | # Checks if value is valid 39 | def check(value, checks): 40 | incorrect = False 41 | 42 | if value == "False": 43 | value = "" 44 | 45 | if not incorrect and "type" in checks: 46 | try: 47 | value = eval(checks["type"])(value) 48 | except Exception: 49 | incorrect = True 50 | 51 | if ( 52 | not incorrect and "options" in checks and value not in checks["options"] 53 | ): # FAILSTATE Value is not one of the options 54 | incorrect = True 55 | if ( 56 | not incorrect 57 | and "regex" in checks 58 | and ( 59 | (isinstance(value, str) and re.match(checks["regex"], value) is None) 60 | or not isinstance(value, str) 61 | ) 62 | ): # FAILSTATE Value doesn't match regex, or has regex but is not a string. 63 | incorrect = True 64 | 65 | if ( 66 | not incorrect 67 | and not hasattr(value, "__iter__") 68 | and ( 69 | ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"]) 70 | or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"]) 71 | ) 72 | ): 73 | incorrect = True 74 | 75 | if ( 76 | not incorrect 77 | and hasattr(value, "__iter__") 78 | and ( 79 | ("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"]) 80 | or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"]) 81 | ) 82 | ): 83 | incorrect = True 84 | 85 | if incorrect: 86 | return "Error" 87 | 88 | return value 89 | 90 | 91 | # Modify settings (after form is submitted) 92 | def modify_settings(data: dict, config_load, checks: dict): 93 | # Modify config settings 94 | def modify_config(obj: dict, name: str, value: any): 95 | for key in obj.keys(): 96 | if name == key: 97 | obj[key] = value 98 | elif not isinstance(obj[key], dict): 99 | continue 100 | else: 101 | modify_config(obj[key], name, value) 102 | 103 | # Remove empty/incorrect key-value pairs 104 | data = {key: value for key, value in data.items() if value and key in checks.keys()} 105 | 106 | # Validate values 107 | for name in data.keys(): 108 | value = check(data[name], checks[name]) 109 | 110 | # Value is invalid 111 | if value == "Error": 112 | flash("Some values were incorrect and didn't save!", "error") 113 | else: 114 | # Value is valid 115 | modify_config(config_load, name, value) 116 | 117 | # Save changes in config.toml 118 | with Path("config.toml").open("w") as toml_file: 119 | toml_file.write(tomlkit.dumps(config_load)) 120 | 121 | flash("Settings saved!") 122 | 123 | return get_config(config_load) 124 | 125 | 126 | # Delete background video 127 | def delete_background(key): 128 | # Read backgrounds.json 129 | with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: 130 | data = json.load(backgrounds) 131 | 132 | # Remove background from backgrounds.json 133 | with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds: 134 | if data.pop(key, None): 135 | json.dump(data, backgrounds, ensure_ascii=False, indent=4) 136 | else: 137 | flash("Couldn't find this background. Try refreshing the page.", "error") 138 | return 139 | 140 | # Remove background video from ".config.template.toml" 141 | config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) 142 | config["settings"]["background"]["background_choice"]["options"].remove(key) 143 | 144 | with Path("utils/.config.template.toml").open("w") as toml_file: 145 | toml_file.write(tomlkit.dumps(config)) 146 | 147 | flash(f'Successfully removed "{key}" background!') 148 | 149 | 150 | # Add background video 151 | def add_background(youtube_uri, filename, citation, position): 152 | # Validate YouTube URI 153 | regex = re.compile(r"(?:\/|%3D|v=|vi=)([0-9A-z\-_]{11})(?:[%#?&]|$)").search(youtube_uri) 154 | 155 | if not regex: 156 | flash("YouTube URI is invalid!", "error") 157 | return 158 | 159 | youtube_uri = f"https://www.youtube.com/watch?v={regex.group(1)}" 160 | 161 | # Check if position is valid 162 | if position == "" or position == "center": 163 | position = "center" 164 | 165 | elif position.isdecimal(): 166 | position = int(position) 167 | 168 | else: 169 | flash('Position is invalid! It can be "center" or decimal number.', "error") 170 | return 171 | 172 | # Sanitize filename 173 | regex = re.compile(r"^([a-zA-Z0-9\s_-]{1,100})$").match(filename) 174 | 175 | if not regex: 176 | flash("Filename is invalid!", "error") 177 | return 178 | 179 | filename = filename.replace(" ", "_") 180 | 181 | # Check if background doesn't already exist 182 | with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds: 183 | data = json.load(backgrounds) 184 | 185 | # Check if key isn't already taken 186 | if filename in list(data.keys()): 187 | flash("Background video with this name already exist!", "error") 188 | return 189 | 190 | # Check if the YouTube URI isn't already used under different name 191 | if youtube_uri in [data[i][0] for i in list(data.keys())]: 192 | flash("Background video with this YouTube URI is already added!", "error") 193 | return 194 | 195 | # Add background video to json file 196 | with open("utils/backgrounds.json", "r+", encoding="utf-8") as backgrounds: 197 | data = json.load(backgrounds) 198 | 199 | data[filename] = [youtube_uri, filename + ".mp4", citation, position] 200 | backgrounds.seek(0) 201 | json.dump(data, backgrounds, ensure_ascii=False, indent=4) 202 | 203 | # Add background video to ".config.template.toml" 204 | config = tomlkit.loads(Path("utils/.config.template.toml").read_text()) 205 | config["settings"]["background"]["background_choice"]["options"].append(filename) 206 | 207 | with Path("utils/.config.template.toml").open("w") as toml_file: 208 | toml_file.write(tomlkit.dumps(config)) 209 | 210 | flash(f'Added "{citation}-{filename}.mp4" as a new background video!') 211 | 212 | return 213 | -------------------------------------------------------------------------------- /utils/id.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from utils.console import print_substep 4 | 5 | 6 | def id(reddit_obj: dict): 7 | """ 8 | This function takes a reddit object and returns the post id 9 | """ 10 | id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) 11 | print_substep(f"Thread ID is {id}", style="bold blue") 12 | return id 13 | -------------------------------------------------------------------------------- /utils/imagenarator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import textwrap 4 | 5 | from PIL import Image, ImageDraw, ImageFont 6 | from rich.progress import track 7 | 8 | from TTS.engine_wrapper import process_text 9 | 10 | 11 | def draw_multiple_line_text( 12 | image, text, font, text_color, padding, wrap=50, transparent=False 13 | ) -> None: 14 | """ 15 | Draw multiline text over given image 16 | """ 17 | draw = ImageDraw.Draw(image) 18 | Fontperm = font.getsize(text) 19 | image_width, image_height = image.size 20 | lines = textwrap.wrap(text, width=wrap) 21 | y = (image_height / 2) - (((Fontperm[1] + (len(lines) * padding) / len(lines)) * len(lines)) / 2) 22 | for line in lines: 23 | line_width, line_height = font.getsize(line) 24 | if transparent: 25 | shadowcolor = "black" 26 | for i in range(1, 5): 27 | draw.text( 28 | ((image_width - line_width) / 2 - i, y - i), 29 | line, 30 | font=font, 31 | fill=shadowcolor, 32 | ) 33 | draw.text( 34 | ((image_width - line_width) / 2 + i, y - i), 35 | line, 36 | font=font, 37 | fill=shadowcolor, 38 | ) 39 | draw.text( 40 | ((image_width - line_width) / 2 - i, y + i), 41 | line, 42 | font=font, 43 | fill=shadowcolor, 44 | ) 45 | draw.text( 46 | ((image_width - line_width) / 2 + i, y + i), 47 | line, 48 | font=font, 49 | fill=shadowcolor, 50 | ) 51 | draw.text(((image_width - line_width) / 2, y), line, font=font, fill=text_color) 52 | y += line_height + padding 53 | 54 | 55 | def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None: 56 | """ 57 | Render Images for video 58 | """ 59 | title = process_text(reddit_obj["thread_title"], False) 60 | texts = reddit_obj["thread_post"] 61 | id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"]) 62 | 63 | if transparent: 64 | font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) 65 | tfont = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) 66 | else: 67 | tfont = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 100) # for title 68 | font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 100) 69 | size = (1920, 1080) 70 | 71 | image = Image.new("RGBA", size, theme) 72 | 73 | # for title 74 | draw_multiple_line_text(image, title, tfont, txtclr, padding, wrap=30, transparent=transparent) 75 | 76 | image.save(f"assets/temp/{id}/png/title.png") 77 | 78 | for idx, text in track(enumerate(texts), "Rendering Image"): 79 | image = Image.new("RGBA", size, theme) 80 | text = process_text(text, False) 81 | draw_multiple_line_text(image, text, font, txtclr, padding, wrap=30, transparent=transparent) 82 | image.save(f"assets/temp/{id}/png/img{idx}.png") 83 | -------------------------------------------------------------------------------- /utils/playwright.py: -------------------------------------------------------------------------------- 1 | def clear_cookie_by_name(context, cookie_cleared_name): 2 | cookies = context.cookies() 3 | filtered_cookies = [cookie for cookie in cookies if cookie["name"] != cookie_cleared_name] 4 | context.clear_cookies() 5 | context.add_cookies(filtered_cookies) 6 | -------------------------------------------------------------------------------- /utils/posttextparser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import time 4 | from typing import List 5 | 6 | import spacy 7 | 8 | from utils.console import print_step 9 | from utils.voice import sanitize_text 10 | 11 | 12 | # working good 13 | def posttextparser(obj, *, tried: bool = False) -> List[str]: 14 | text: str = re.sub("\n", " ", obj) 15 | try: 16 | nlp = spacy.load("en_core_web_sm") 17 | except OSError as e: 18 | if not tried: 19 | os.system("python -m spacy download en_core_web_sm") 20 | time.sleep(5) 21 | return posttextparser(obj, tried=True) 22 | print_step( 23 | "The spacy model can't load. You need to install it with the command \npython -m spacy download en_core_web_sm " 24 | ) 25 | raise e 26 | 27 | doc = nlp(text) 28 | 29 | newtext: list = [] 30 | 31 | for line in doc.sents: 32 | if sanitize_text(line.text): 33 | newtext.append(line.text) 34 | 35 | return newtext 36 | -------------------------------------------------------------------------------- /utils/settings.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Tuple, Dict 4 | 5 | import toml 6 | from rich.console import Console 7 | 8 | from utils.console import handle_input 9 | 10 | console = Console() 11 | config = dict # autocomplete 12 | 13 | 14 | def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None): 15 | if path is None: # path Default argument value is mutable 16 | path = [] 17 | for key in obj.keys(): 18 | if type(obj[key]) is dict: 19 | crawl(obj[key], func, path + [key]) 20 | continue 21 | func(path + [key], obj[key]) 22 | 23 | 24 | def check(value, checks, name): 25 | def get_check_value(key, default_result): 26 | return checks[key] if key in checks else default_result 27 | 28 | incorrect = False 29 | if value == {}: 30 | incorrect = True 31 | if not incorrect and "type" in checks: 32 | try: 33 | value = eval(checks["type"])(value) 34 | except: 35 | incorrect = True 36 | 37 | if ( 38 | not incorrect and "options" in checks and value not in checks["options"] 39 | ): # FAILSTATE Value is not one of the options 40 | incorrect = True 41 | if ( 42 | not incorrect 43 | and "regex" in checks 44 | and ( 45 | (isinstance(value, str) and re.match(checks["regex"], value) is None) 46 | or not isinstance(value, str) 47 | ) 48 | ): # FAILSTATE Value doesn't match regex, or has regex but is not a string. 49 | incorrect = True 50 | 51 | if ( 52 | not incorrect 53 | and not hasattr(value, "__iter__") 54 | and ( 55 | ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"]) 56 | or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"]) 57 | ) 58 | ): 59 | incorrect = True 60 | if ( 61 | not incorrect 62 | and hasattr(value, "__iter__") 63 | and ( 64 | ("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"]) 65 | or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"]) 66 | ) 67 | ): 68 | incorrect = True 69 | 70 | if incorrect: 71 | value = handle_input( 72 | message=( 73 | (("[blue]Example: " + str(checks["example"]) + "\n") if "example" in checks else "") 74 | + "[red]" 75 | + ("Non-optional ", "Optional ")["optional" in checks and checks["optional"] is True] 76 | ) 77 | + "[#C0CAF5 bold]" 78 | + str(name) 79 | + "[#F7768E bold]=", 80 | extra_info=get_check_value("explanation", ""), 81 | check_type=eval(get_check_value("type", "False")), 82 | default=get_check_value("default", NotImplemented), 83 | match=get_check_value("regex", ""), 84 | err_message=get_check_value("input_error", "Incorrect input"), 85 | nmin=get_check_value("nmin", None), 86 | nmax=get_check_value("nmax", None), 87 | oob_error=get_check_value( 88 | "oob_error", "Input out of bounds(Value too high/low/long/short)" 89 | ), 90 | options=get_check_value("options", None), 91 | optional=get_check_value("optional", False), 92 | ) 93 | return value 94 | 95 | 96 | def crawl_and_check(obj: dict, path: list, checks: dict = {}, name=""): 97 | if len(path) == 0: 98 | return check(obj, checks, name) 99 | if path[0] not in obj.keys(): 100 | obj[path[0]] = {} 101 | obj[path[0]] = crawl_and_check(obj[path[0]], path[1:], checks, path[0]) 102 | return obj 103 | 104 | 105 | def check_vars(path, checks): 106 | global config 107 | crawl_and_check(config, path, checks) 108 | 109 | 110 | def check_toml(template_file, config_file) -> Tuple[bool, Dict]: 111 | global config 112 | config = None 113 | try: 114 | template = toml.load(template_file) 115 | except Exception as error: 116 | console.print(f"[red bold]Encountered error when trying to to load {template_file}: {error}") 117 | return False 118 | try: 119 | config = toml.load(config_file) 120 | except toml.TomlDecodeError: 121 | console.print( 122 | f"""[blue]Couldn't read {config_file}. 123 | Overwrite it?(y/n)""" 124 | ) 125 | if not input().startswith("y"): 126 | print("Unable to read config, and not allowed to overwrite it. Giving up.") 127 | return False 128 | else: 129 | try: 130 | with open(config_file, "w") as f: 131 | f.write("") 132 | except: 133 | console.print( 134 | f"[red bold]Failed to overwrite {config_file}. Giving up.\nSuggestion: check {config_file} permissions for the user." 135 | ) 136 | return False 137 | except FileNotFoundError: 138 | console.print( 139 | f"""[blue]Couldn't find {config_file} 140 | Creating it now.""" 141 | ) 142 | try: 143 | with open(config_file, "x") as f: 144 | f.write("") 145 | config = {} 146 | except: 147 | console.print( 148 | f"[red bold]Failed to write to {config_file}. Giving up.\nSuggestion: check the folder's permissions for the user." 149 | ) 150 | return False 151 | 152 | console.print( 153 | """\ 154 | [blue bold]############################### 155 | # # 156 | # Checking TOML configuration # 157 | # # 158 | ############################### 159 | If you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\ 160 | """ 161 | ) 162 | crawl(template, check_vars) 163 | with open(config_file, "w") as f: 164 | toml.dump(config, f) 165 | return config 166 | 167 | 168 | if __name__ == "__main__": 169 | directory = Path().absolute() 170 | check_toml(f"{directory}/utils/.config.template.toml", "config.toml") 171 | -------------------------------------------------------------------------------- /utils/subreddit.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os.path import exists 3 | 4 | from utils import settings 5 | from utils.ai_methods import sort_by_similarity 6 | from utils.console import print_substep 7 | 8 | 9 | def get_subreddit_undone(submissions: list, subreddit, times_checked=0, similarity_scores=None): 10 | """_summary_ 11 | 12 | Args: 13 | submissions (list): List of posts that are going to potentially be generated into a video 14 | subreddit (praw.Reddit.SubredditHelper): Chosen subreddit 15 | 16 | Returns: 17 | Any: The submission that has not been done 18 | """ 19 | # Second try of getting a valid Submission 20 | if times_checked and settings.config["ai"]["ai_similarity_enabled"]: 21 | print("Sorting based on similarity for a different date filter and thread limit..") 22 | submissions = sort_by_similarity( 23 | submissions, keywords=settings.config["ai"]["ai_similarity_enabled"] 24 | ) 25 | 26 | # recursively checks if the top submission in the list was already done. 27 | if not exists("./video_creation/data/videos.json"): 28 | with open("./video_creation/data/videos.json", "w+") as f: 29 | json.dump([], f) 30 | with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: 31 | done_videos = json.load(done_vids_raw) 32 | for i, submission in enumerate(submissions): 33 | if already_done(done_videos, submission): 34 | continue 35 | if submission.over_18: 36 | try: 37 | if not settings.config["settings"]["allow_nsfw"]: 38 | print_substep("NSFW Post Detected. Skipping...") 39 | continue 40 | except AttributeError: 41 | print_substep("NSFW settings not defined. Skipping NSFW post...") 42 | if submission.stickied: 43 | print_substep("This post was pinned by moderators. Skipping...") 44 | continue 45 | if ( 46 | submission.num_comments <= int(settings.config["reddit"]["thread"]["min_comments"]) 47 | and not settings.config["settings"]["storymode"] 48 | ): 49 | print_substep( 50 | f'This post has under the specified minimum of comments ({settings.config["reddit"]["thread"]["min_comments"]}). Skipping...' 51 | ) 52 | continue 53 | if settings.config["settings"]["storymode"]: 54 | if not submission.selftext: 55 | print_substep("You are trying to use story mode on post with no post text") 56 | continue 57 | else: 58 | # Check for the length of the post text 59 | if len(submission.selftext) > ( 60 | settings.config["settings"]["storymode_max_length"] or 2000 61 | ): 62 | print_substep( 63 | f"Post is too long ({len(submission.selftext)}), try with a different post. ({settings.config['settings']['storymode_max_length']} character limit)" 64 | ) 65 | continue 66 | elif len(submission.selftext) < 30: 67 | continue 68 | if settings.config["settings"]["storymode"] and not submission.is_self: 69 | continue 70 | if similarity_scores is not None: 71 | return submission, similarity_scores[i].item() 72 | return submission 73 | print("all submissions have been done going by top submission order") 74 | VALID_TIME_FILTERS = [ 75 | "day", 76 | "hour", 77 | "month", 78 | "week", 79 | "year", 80 | "all", 81 | ] # set doesn't have __getitem__ 82 | index = times_checked + 1 83 | if index == len(VALID_TIME_FILTERS): 84 | print("All submissions have been done.") 85 | 86 | return get_subreddit_undone( 87 | subreddit.top( 88 | time_filter=VALID_TIME_FILTERS[index], 89 | limit=(50 if int(index) == 0 else index + 1 * 50), 90 | ), 91 | subreddit, 92 | times_checked=index, 93 | ) # all the videos in hot have already been done 94 | 95 | 96 | def already_done(done_videos: list, submission) -> bool: 97 | """Checks to see if the given submission is in the list of videos 98 | 99 | Args: 100 | done_videos (list): Finished videos 101 | submission (Any): The submission 102 | 103 | Returns: 104 | Boolean: Whether the video was found in the list 105 | """ 106 | 107 | for video in done_videos: 108 | if video["id"] == str(submission): 109 | return True 110 | return False 111 | -------------------------------------------------------------------------------- /utils/thumbnail.py: -------------------------------------------------------------------------------- 1 | from PIL import ImageDraw, ImageFont 2 | 3 | 4 | def create_thumbnail(thumbnail, font_family, font_size, font_color, width, height, title): 5 | font = ImageFont.truetype(font_family + ".ttf", font_size) 6 | Xaxis = width - (width * 0.2) # 20% of the width 7 | sizeLetterXaxis = font_size * 0.5 # 50% of the font size 8 | XaxisLetterQty = round(Xaxis / sizeLetterXaxis) # Quantity of letters that can fit in the X axis 9 | MarginYaxis = height * 0.12 # 12% of the height 10 | MarginXaxis = width * 0.05 # 5% of the width 11 | # 1.1 rem 12 | LineHeight = font_size * 1.1 13 | # rgb = "255,255,255" transform to list 14 | rgb = font_color.split(",") 15 | rgb = (int(rgb[0]), int(rgb[1]), int(rgb[2])) 16 | 17 | arrayTitle = [] 18 | for word in title.split(): 19 | if len(arrayTitle) == 0: 20 | # colocar a primeira palavra no arrayTitl# put the first word in the arrayTitle 21 | arrayTitle.append(word) 22 | else: 23 | # if the size of arrayTitle is less than qtLetters 24 | if len(arrayTitle[-1]) + len(word) < XaxisLetterQty: 25 | arrayTitle[-1] = arrayTitle[-1] + " " + word 26 | else: 27 | arrayTitle.append(word) 28 | 29 | draw = ImageDraw.Draw(thumbnail) 30 | # loop for put the title in the thumbnail 31 | for i in range(0, len(arrayTitle)): 32 | # 1.1 rem 33 | draw.text((MarginXaxis, MarginYaxis + (LineHeight * i)), arrayTitle[i], rgb, font=font) 34 | 35 | return thumbnail 36 | -------------------------------------------------------------------------------- /utils/version.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from utils.console import print_step 4 | 5 | 6 | def checkversion(__VERSION__: str): 7 | response = requests.get( 8 | "https://api.github.com/repos/elebumm/RedditVideoMakerBot/releases/latest" 9 | ) 10 | latestversion = response.json()["tag_name"] 11 | if __VERSION__ == latestversion: 12 | print_step(f"You are using the newest version ({__VERSION__}) of the bot") 13 | return True 14 | elif __VERSION__ < latestversion: 15 | print_step( 16 | f"You are using an older version ({__VERSION__}) of the bot. Download the newest version ({latestversion}) from https://github.com/elebumm/RedditVideoMakerBot/releases/latest" 17 | ) 18 | else: 19 | print_step( 20 | f"Welcome to the test version ({__VERSION__}) of the bot. Thanks for testing and feel free to report any bugs you find." 21 | ) 22 | -------------------------------------------------------------------------------- /utils/videos.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | from praw.models import Submission 5 | 6 | from utils import settings 7 | from utils.console import print_step 8 | 9 | 10 | def check_done( 11 | redditobj: Submission, 12 | ) -> Submission: 13 | # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack 14 | """Checks if the chosen post has already been generated 15 | 16 | Args: 17 | redditobj (Submission): Reddit object gotten from reddit/subreddit.py 18 | 19 | Returns: 20 | Submission|None: Reddit object in args 21 | """ 22 | with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: 23 | done_videos = json.load(done_vids_raw) 24 | for video in done_videos: 25 | if video["id"] == str(redditobj): 26 | if settings.config["reddit"]["thread"]["post_id"]: 27 | print_step( 28 | "You already have done this video but since it was declared specifically in the config file the program will continue" 29 | ) 30 | return redditobj 31 | print_step("Getting new post as the current one has already been done") 32 | return None 33 | return redditobj 34 | 35 | 36 | def save_data(subreddit: str, filename: str, reddit_title: str, reddit_id: str, credit: str): 37 | """Saves the videos that have already been generated to a JSON file in video_creation/data/videos.json 38 | 39 | Args: 40 | filename (str): The finished video title name 41 | @param subreddit: 42 | @param filename: 43 | @param reddit_id: 44 | @param reddit_title: 45 | """ 46 | with open("./video_creation/data/videos.json", "r+", encoding="utf-8") as raw_vids: 47 | done_vids = json.load(raw_vids) 48 | if reddit_id in [video["id"] for video in done_vids]: 49 | return # video already done but was specified to continue anyway in the config file 50 | payload = { 51 | "subreddit": subreddit, 52 | "id": reddit_id, 53 | "time": str(int(time.time())), 54 | "background_credit": credit, 55 | "reddit_title": reddit_title, 56 | "filename": filename, 57 | } 58 | done_vids.append(payload) 59 | raw_vids.seek(0) 60 | json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4) 61 | -------------------------------------------------------------------------------- /utils/voice.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import time as pytime 4 | from datetime import datetime 5 | from time import sleep 6 | 7 | from cleantext import clean 8 | from requests import Response 9 | 10 | from utils import settings 11 | 12 | if sys.version_info[0] >= 3: 13 | from datetime import timezone 14 | 15 | 16 | def check_ratelimit(response: Response) -> bool: 17 | """ 18 | Checks if the response is a ratelimit response. 19 | If it is, it sleeps for the time specified in the response. 20 | """ 21 | if response.status_code == 429: 22 | try: 23 | time = int(response.headers["X-RateLimit-Reset"]) 24 | print(f"Ratelimit hit. Sleeping for {time - int(pytime.time())} seconds.") 25 | sleep_until(time) 26 | return False 27 | except KeyError: # if the header is not present, we don't know how long to wait 28 | return False 29 | 30 | return True 31 | 32 | 33 | def sleep_until(time) -> None: 34 | """ 35 | Pause your program until a specific end time. 36 | 'time' is either a valid datetime object or unix timestamp in seconds (i.e. seconds since Unix epoch) 37 | """ 38 | end = time 39 | 40 | # Convert datetime to unix timestamp and adjust for locality 41 | if isinstance(time, datetime): 42 | # If we're on Python 3 and the user specified a timezone, convert to UTC and get the timestamp. 43 | if sys.version_info[0] >= 3 and time.tzinfo: 44 | end = time.astimezone(timezone.utc).timestamp() 45 | else: 46 | zoneDiff = pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds() 47 | end = (time - datetime(1970, 1, 1)).total_seconds() + zoneDiff 48 | 49 | # Type check 50 | if not isinstance(end, (int, float)): 51 | raise Exception("The time parameter is not a number or datetime object") 52 | 53 | # Now we wait 54 | while True: 55 | now = pytime.time() 56 | diff = end - now 57 | 58 | # 59 | # Time is up! 60 | # 61 | if diff <= 0: 62 | break 63 | else: 64 | # 'logarithmic' sleeping to minimize loop iterations 65 | sleep(diff / 2) 66 | 67 | 68 | def sanitize_text(text: str) -> str: 69 | r"""Sanitizes the text for tts. 70 | What gets removed: 71 | - following characters`^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+` 72 | - any http or https links 73 | 74 | Args: 75 | text (str): Text to be sanitized 76 | 77 | Returns: 78 | str: Sanitized text 79 | """ 80 | 81 | # remove any urls from the text 82 | regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*" 83 | 84 | result = re.sub(regex_urls, " ", text) 85 | 86 | # note: not removing apostrophes 87 | regex_expr = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-%—“”‘\"%\*/{}\[\]\(\)\\|<>=+]" 88 | result = re.sub(regex_expr, " ", result) 89 | result = result.replace("+", "plus").replace("&", "and") 90 | 91 | # emoji removal if the setting is enabled 92 | if settings.config["settings"]["tts"]["no_emojis"]: 93 | result = clean(result, no_emoji=True) 94 | 95 | # remove extra whitespace 96 | return " ".join(result.split()) 97 | -------------------------------------------------------------------------------- /video_creation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cyteon/RedditVideoMakerBot/64f2322ba049cdda8773f1f73b5cfde149d31ef1/video_creation/__init__.py -------------------------------------------------------------------------------- /video_creation/background.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | from pathlib import Path 5 | from random import randrange 6 | from typing import Any, Tuple, Dict 7 | 8 | import yt_dlp 9 | from moviepy.editor import VideoFileClip, AudioFileClip 10 | from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip 11 | 12 | from utils import settings 13 | from utils.console import print_step, print_substep 14 | 15 | 16 | def load_background_options(): 17 | background_options = {} 18 | # Load background videos 19 | with open("./utils/background_videos.json") as json_file: 20 | background_options["video"] = json.load(json_file) 21 | 22 | # Load background audios 23 | with open("./utils/background_audios.json") as json_file: 24 | background_options["audio"] = json.load(json_file) 25 | 26 | # Remove "__comment" from backgrounds 27 | del background_options["video"]["__comment"] 28 | del background_options["audio"]["__comment"] 29 | 30 | for name in list(background_options["video"].keys()): 31 | pos = background_options["video"][name][3] 32 | 33 | if pos != "center": 34 | background_options["video"][name][3] = lambda t: ("center", pos + t) 35 | 36 | return background_options 37 | 38 | 39 | def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]: 40 | """Generates a random interval of time to be used as the background of the video. 41 | 42 | Args: 43 | video_length (int): Length of the video 44 | length_of_clip (int): Length of the video to be used as the background 45 | 46 | Returns: 47 | tuple[int,int]: Start and end time of the randomized interval 48 | """ 49 | initialValue = 180 50 | # Issue #1649 - Ensures that will be a valid interval in the video 51 | while int(length_of_clip) <= int(video_length + initialValue): 52 | if initialValue == initialValue // 2: 53 | raise Exception("Your background is too short for this video length") 54 | else: 55 | initialValue //= 2 # Divides the initial value by 2 until reach 0 56 | random_time = randrange(initialValue, int(length_of_clip) - int(video_length)) 57 | return random_time, random_time + video_length 58 | 59 | 60 | def get_background_config(mode: str): 61 | """Fetch the background/s configuration""" 62 | try: 63 | choice = str(settings.config["settings"]["background"][f"background_{mode}"]).casefold() 64 | except AttributeError: 65 | print_substep("No background selected. Picking random background'") 66 | choice = None 67 | 68 | # Handle default / not supported background using default option. 69 | # Default : pick random from supported background. 70 | if not choice or choice not in background_options[mode]: 71 | choice = random.choice(list(background_options[mode].keys())) 72 | 73 | return background_options[mode][choice] 74 | 75 | 76 | def download_background_video(background_config: Tuple[str, str, str, Any]): 77 | """Downloads the background/s video from YouTube.""" 78 | Path("./assets/backgrounds/video/").mkdir(parents=True, exist_ok=True) 79 | # note: make sure the file name doesn't include an - in it 80 | uri, filename, credit, _ = background_config 81 | if Path(f"assets/backgrounds/video/{credit}-{filename}").is_file(): 82 | return 83 | print_step( 84 | "We need to download the backgrounds videos. they are fairly large but it's only done once. 😎" 85 | ) 86 | print_substep("Downloading the backgrounds videos... please be patient 🙏 ") 87 | print_substep(f"Downloading {filename} from {uri}") 88 | ydl_opts = { 89 | "format": "bestvideo[height<=1080][ext=mp4]", 90 | "outtmpl": f"assets/backgrounds/video/{credit}-{filename}", 91 | "retries": 10, 92 | } 93 | 94 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 95 | ydl.download(uri) 96 | print_substep("Background video downloaded successfully! 🎉", style="bold green") 97 | 98 | 99 | def download_background_audio(background_config: Tuple[str, str, str]): 100 | """Downloads the background/s audio from YouTube.""" 101 | Path("./assets/backgrounds/audio/").mkdir(parents=True, exist_ok=True) 102 | # note: make sure the file name doesn't include an - in it 103 | uri, filename, credit = background_config 104 | if Path(f"assets/backgrounds/audio/{credit}-{filename}").is_file(): 105 | return 106 | print_step( 107 | "We need to download the backgrounds audio. they are fairly large but it's only done once. 😎" 108 | ) 109 | print_substep("Downloading the backgrounds audio... please be patient 🙏 ") 110 | print_substep(f"Downloading {filename} from {uri}") 111 | ydl_opts = { 112 | "outtmpl": f"./assets/backgrounds/audio/{credit}-{filename}", 113 | "format": "bestaudio/best", 114 | "extract_audio": True, 115 | } 116 | 117 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 118 | ydl.download([uri]) 119 | 120 | print_substep("Background audio downloaded successfully! 🎉", style="bold green") 121 | 122 | 123 | def chop_background(background_config: Dict[str, Tuple], video_length: int, reddit_object: dict): 124 | """Generates the background audio and footage to be used in the video and writes it to assets/temp/background.mp3 and assets/temp/background.mp4 125 | 126 | Args: 127 | background_config (Dict[str,Tuple]]) : Current background configuration 128 | video_length (int): Length of the clip where the background footage is to be taken out of 129 | """ 130 | id = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"]) 131 | 132 | if settings.config["settings"]["background"][f"background_audio_volume"] == 0: 133 | print_step("Volume was set to 0. Skipping background audio creation . . .") 134 | else: 135 | print_step("Finding a spot in the backgrounds audio to chop...✂️") 136 | audio_choice = f"{background_config['audio'][2]}-{background_config['audio'][1]}" 137 | background_audio = AudioFileClip(f"assets/backgrounds/audio/{audio_choice}") 138 | start_time_audio, end_time_audio = get_start_and_end_times( 139 | video_length, background_audio.duration 140 | ) 141 | background_audio = background_audio.subclip(start_time_audio, end_time_audio) 142 | background_audio.write_audiofile(f"assets/temp/{id}/background.mp3") 143 | 144 | print_step("Finding a spot in the backgrounds video to chop...✂️") 145 | video_choice = f"{background_config['video'][2]}-{background_config['video'][1]}" 146 | background_video = VideoFileClip(f"assets/backgrounds/video/{video_choice}") 147 | start_time_video, end_time_video = get_start_and_end_times( 148 | video_length, background_video.duration 149 | ) 150 | # Extract video subclip 151 | try: 152 | ffmpeg_extract_subclip( 153 | f"assets/backgrounds/video/{video_choice}", 154 | start_time_video, 155 | end_time_video, 156 | targetname=f"assets/temp/{id}/background.mp4", 157 | ) 158 | except (OSError, IOError): # ffmpeg issue see #348 159 | print_substep("FFMPEG issue. Trying again...") 160 | with VideoFileClip(f"assets/backgrounds/video/{video_choice}") as video: 161 | new = video.subclip(start_time_video, end_time_video) 162 | new.write_videofile(f"assets/temp/{id}/background.mp4") 163 | print_substep("Background video chopped successfully!", style="bold green") 164 | return background_config["video"][2] 165 | 166 | 167 | # Create a tuple for downloads background (background_audio_options, background_video_options) 168 | background_options = load_background_options() 169 | -------------------------------------------------------------------------------- /video_creation/data/cookie-dark-mode.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "USER", 4 | "value": "eyJwcmVmcyI6eyJ0b3BDb250ZW50RGlzbWlzc2FsVGltZSI6MCwiZ2xvYmFsVGhlbWUiOiJSRURESVQiLCJuaWdodG1vZGUiOnRydWUsImNvbGxhcHNlZFRyYXlTZWN0aW9ucyI6eyJmYXZvcml0ZXMiOmZhbHNlLCJtdWx0aXMiOmZhbHNlLCJtb2RlcmF0aW5nIjpmYWxzZSwic3Vic2NyaXB0aW9ucyI6ZmFsc2UsInByb2ZpbGVzIjpmYWxzZX0sInRvcENvbnRlbnRUaW1lc0Rpc21pc3NlZCI6MH19", 5 | "domain": ".reddit.com", 6 | "path": "/" 7 | }, 8 | { 9 | "name": "eu_cookie", 10 | "value": "{%22opted%22:true%2C%22nonessential%22:false}", 11 | "domain": ".reddit.com", 12 | "path": "/" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /video_creation/data/cookie-light-mode.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "eu_cookie", 4 | "value": "{%22opted%22:true%2C%22nonessential%22:false}", 5 | "domain": ".reddit.com", 6 | "path": "/" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /video_creation/screenshot_downloader.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from pathlib import Path 4 | from typing import Dict, Final 5 | 6 | import translators 7 | from playwright.sync_api import ViewportSize, sync_playwright 8 | from rich.progress import track 9 | 10 | from utils import settings 11 | from utils.console import print_step, print_substep 12 | from utils.imagenarator import imagemaker 13 | from utils.playwright import clear_cookie_by_name 14 | from utils.videos import save_data 15 | 16 | __all__ = ["download_screenshots_of_reddit_posts"] 17 | 18 | 19 | def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int): 20 | """Downloads screenshots of reddit posts as seen on the web. Downloads to assets/temp/png 21 | 22 | Args: 23 | reddit_object (Dict): Reddit object received from reddit/subreddit.py 24 | screenshot_num (int): Number of screenshots to download 25 | """ 26 | # settings values 27 | W: Final[int] = int(settings.config["settings"]["resolution_w"]) 28 | H: Final[int] = int(settings.config["settings"]["resolution_h"]) 29 | lang: Final[str] = settings.config["reddit"]["thread"]["post_lang"] 30 | storymode: Final[bool] = settings.config["settings"]["storymode"] 31 | 32 | print_step("Downloading screenshots of reddit posts...") 33 | reddit_id = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"]) 34 | # ! Make sure the reddit screenshots folder exists 35 | Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True) 36 | 37 | # set the theme and disable non-essential cookies 38 | if settings.config["settings"]["theme"] == "dark": 39 | cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8") 40 | bgcolor = (33, 33, 36, 255) 41 | txtcolor = (240, 240, 240) 42 | transparent = False 43 | elif settings.config["settings"]["theme"] == "transparent": 44 | if storymode: 45 | # Transparent theme 46 | bgcolor = (0, 0, 0, 0) 47 | txtcolor = (255, 255, 255) 48 | transparent = True 49 | cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8") 50 | else: 51 | # Switch to dark theme 52 | cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8") 53 | bgcolor = (33, 33, 36, 255) 54 | txtcolor = (240, 240, 240) 55 | transparent = False 56 | else: 57 | cookie_file = open("./video_creation/data/cookie-light-mode.json", encoding="utf-8") 58 | bgcolor = (255, 255, 255, 255) 59 | txtcolor = (0, 0, 0) 60 | transparent = False 61 | if storymode and settings.config["settings"]["storymodemethod"] == 1: 62 | # for idx,item in enumerate(reddit_object["thread_post"]): 63 | print_substep("Generating images...") 64 | return imagemaker( 65 | theme=bgcolor, 66 | reddit_obj=reddit_object, 67 | txtclr=txtcolor, 68 | transparent=transparent, 69 | ) 70 | 71 | screenshot_num: int 72 | with sync_playwright() as p: 73 | print_substep("Launching Headless Browser...") 74 | 75 | browser = p.chromium.launch( 76 | headless=False 77 | ) # headless=False will show the browser for debugging purposes 78 | # Device scale factor (or dsf for short) allows us to increase the resolution of the screenshots 79 | # When the dsf is 1, the width of the screenshot is 600 pixels 80 | # so we need a dsf such that the width of the screenshot is greater than the final resolution of the video 81 | dsf = (W // 600) + 1 82 | 83 | context = browser.new_context( 84 | locale=lang or "en-us", 85 | color_scheme="dark", 86 | viewport=ViewportSize(width=W, height=H), 87 | device_scale_factor=dsf, 88 | ) 89 | cookies = json.load(cookie_file) 90 | cookie_file.close() 91 | 92 | context.add_cookies(cookies) # load preference cookies 93 | 94 | # Login to Reddit 95 | print_substep("Logging in to Reddit...") 96 | page = context.new_page() 97 | page.goto("https://www.reddit.com/login", timeout=0) 98 | page.set_viewport_size(ViewportSize(width=1920, height=1080)) 99 | page.wait_for_load_state() 100 | 101 | page.locator(f'input[name="username"]').fill(settings.config["reddit"]["creds"]["username"]) 102 | page.locator(f'input[name="password"]').fill(settings.config["reddit"]["creds"]["password"]) 103 | page.get_by_role("button", name="Log In").click() 104 | page.wait_for_timeout(5000) 105 | 106 | login_error_div = page.locator(".AnimatedForm__errorMessage").first 107 | if login_error_div.is_visible(): 108 | login_error_message = login_error_div.inner_text() 109 | if login_error_message.strip() == "": 110 | # The div element is empty, no error 111 | pass 112 | else: 113 | # The div contains an error message 114 | print_substep( 115 | "Your reddit credentials are incorrect! Please modify them accordingly in the config.toml file.", 116 | style="red", 117 | ) 118 | exit() 119 | else: 120 | pass 121 | 122 | page.wait_for_load_state() 123 | # Handle the redesign 124 | # Check if the redesign optout cookie is set 125 | if page.locator("#redesign-beta-optin-btn").is_visible(): 126 | # Clear the redesign optout cookie 127 | clear_cookie_by_name(context, "redesign_optout") 128 | # Reload the page for the redesign to take effect 129 | page.reload() 130 | # Get the thread screenshot 131 | page.goto(reddit_object["thread_url"], timeout=0) 132 | page.set_viewport_size(ViewportSize(width=W, height=H)) 133 | page.wait_for_load_state() 134 | page.wait_for_timeout(5000) 135 | 136 | if page.locator( 137 | "#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button" 138 | ).is_visible(): 139 | # This means the post is NSFW and requires to click the proceed button. 140 | 141 | print_substep("Post is NSFW. You are spicy...") 142 | page.locator( 143 | "#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button" 144 | ).click() 145 | page.wait_for_load_state() # Wait for page to fully load 146 | 147 | # translate code 148 | if page.locator( 149 | "#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i" 150 | ).is_visible(): 151 | page.locator( 152 | "#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i" 153 | ).click() # Interest popup is showing, this code will close it 154 | 155 | if lang: 156 | print_substep("Translating post...") 157 | texts_in_tl = translators.translate_text( 158 | reddit_object["thread_title"], 159 | to_language=lang, 160 | translator="google", 161 | ) 162 | 163 | page.evaluate( 164 | "tl_content => document.querySelector('[data-adclicklocation=\"title\"] > div > div > h1').textContent = tl_content", 165 | texts_in_tl, 166 | ) 167 | else: 168 | print_substep("Skipping translation...") 169 | 170 | postcontentpath = f"assets/temp/{reddit_id}/png/title.png" 171 | try: 172 | if settings.config["settings"]["zoom"] != 1: 173 | # store zoom settings 174 | zoom = settings.config["settings"]["zoom"] 175 | # zoom the body of the page 176 | page.evaluate("document.body.style.zoom=" + str(zoom)) 177 | # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom 178 | location = page.locator('[data-test-id="post-content"]').bounding_box() 179 | for i in location: 180 | location[i] = float("{:.2f}".format(location[i] * zoom)) 181 | page.screenshot(clip=location, path=postcontentpath) 182 | else: 183 | page.locator('[data-test-id="post-content"]').screenshot(path=postcontentpath) 184 | except Exception as e: 185 | print_substep("Something went wrong!", style="red") 186 | resp = input( 187 | "Something went wrong with making the screenshots! Do you want to skip the post? (y/n) " 188 | ) 189 | 190 | if resp.casefold().startswith("y"): 191 | save_data("", "", "skipped", reddit_id, "") 192 | print_substep( 193 | "The post is successfully skipped! You can now restart the program and this post will skipped.", 194 | "green", 195 | ) 196 | 197 | resp = input("Do you want the error traceback for debugging purposes? (y/n)") 198 | if not resp.casefold().startswith("y"): 199 | exit() 200 | 201 | raise e 202 | 203 | if storymode: 204 | page.locator('[data-click-id="text"]').first.screenshot( 205 | path=f"assets/temp/{reddit_id}/png/story_content.png" 206 | ) 207 | else: 208 | for idx, comment in enumerate( 209 | track( 210 | reddit_object["comments"][:screenshot_num], 211 | "Downloading screenshots...", 212 | ) 213 | ): 214 | # Stop if we have reached the screenshot_num 215 | if idx >= screenshot_num: 216 | break 217 | 218 | if page.locator('[data-testid="content-gate"]').is_visible(): 219 | page.locator('[data-testid="content-gate"] button').click() 220 | 221 | page.goto(f"https://new.reddit.com/{comment['comment_url']}") 222 | 223 | # translate code 224 | 225 | if settings.config["reddit"]["thread"]["post_lang"]: 226 | comment_tl = translators.translate_text( 227 | comment["comment_body"], 228 | translator="google", 229 | to_language=settings.config["reddit"]["thread"]["post_lang"], 230 | ) 231 | page.evaluate( 232 | '([tl_content, tl_id]) => document.querySelector(`#t1_${tl_id} > div:nth-child(2) > div > div[data-testid="comment"] > div`).textContent = tl_content', 233 | [comment_tl, comment["comment_id"]], 234 | ) 235 | try: 236 | if settings.config["settings"]["zoom"] != 1: 237 | # store zoom settings 238 | zoom = settings.config["settings"]["zoom"] 239 | # zoom the body of the page 240 | page.evaluate("document.body.style.zoom=" + str(zoom)) 241 | # scroll comment into view 242 | page.locator(f"#t1_{comment['comment_id']}").scroll_into_view_if_needed() 243 | # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom 244 | location = page.locator(f"#t1_{comment['comment_id']}").bounding_box() 245 | for i in location: 246 | location[i] = float("{:.2f}".format(location[i] * zoom)) 247 | page.screenshot( 248 | clip=location, 249 | path=f"assets/temp/{reddit_id}/png/comment_{idx}.png", 250 | ) 251 | else: 252 | page.locator(f"#t1_{comment['comment_id']}").screenshot( 253 | path=f"assets/temp/{reddit_id}/png/comment_{idx}.png" 254 | ) 255 | except TimeoutError: 256 | del reddit_object["comments"] 257 | screenshot_num += 1 258 | print("TimeoutError: Skipping screenshot...") 259 | continue 260 | 261 | # close browser instance when we are done using it 262 | browser.close() 263 | 264 | print_substep("Screenshots downloaded Successfully.", style="bold green") -------------------------------------------------------------------------------- /video_creation/voices.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from rich.console import Console 4 | 5 | from TTS.GTTS import GTTS 6 | from TTS.TikTok import TikTok 7 | from TTS.aws_polly import AWSPolly 8 | from TTS.elevenlabs import elevenlabs 9 | from TTS.engine_wrapper import TTSEngine 10 | from TTS.pyttsx import pyttsx 11 | from TTS.streamlabs_polly import StreamlabsPolly 12 | from utils import settings 13 | from utils.console import print_table, print_step 14 | 15 | console = Console() 16 | 17 | TTSProviders = { 18 | "GoogleTranslate": GTTS, 19 | "AWSPolly": AWSPolly, 20 | "StreamlabsPolly": StreamlabsPolly, 21 | "TikTok": TikTok, 22 | "pyttsx": pyttsx, 23 | "ElevenLabs": elevenlabs, 24 | } 25 | 26 | 27 | def save_text_to_mp3(reddit_obj) -> Tuple[int, int]: 28 | """Saves text to MP3 files. 29 | 30 | Args: 31 | reddit_obj (): Reddit object received from reddit API in reddit/subreddit.py 32 | 33 | Returns: 34 | tuple[int,int]: (total length of the audio, the number of comments audio was generated for) 35 | """ 36 | 37 | voice = settings.config["settings"]["tts"]["voice_choice"] 38 | if str(voice).casefold() in map(lambda _: _.casefold(), TTSProviders): 39 | text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, voice), reddit_obj) 40 | else: 41 | while True: 42 | print_step("Please choose one of the following TTS providers: ") 43 | print_table(TTSProviders) 44 | choice = input("\n") 45 | if choice.casefold() in map(lambda _: _.casefold(), TTSProviders): 46 | break 47 | print("Unknown Choice") 48 | text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, choice), reddit_obj) 49 | return text_to_mp3.run() 50 | 51 | 52 | def get_case_insensitive_key_value(input_dict, key): 53 | return next( 54 | (value for dict_key, value in input_dict.items() if dict_key.lower() == key.lower()), 55 | None, 56 | ) 57 | --------------------------------------------------------------------------------