Generate Short-form media from UGC
33 | content from the worst website on the internet
34 |
35 | ## Table of contents
36 | - [Why](#why-this-project)
37 | - [Features](#features)
38 | - [Installation](#installation)
39 | - [Optional](#optional)
40 | - [Quick Start](#getting-started)
41 | - [Customization](#making-it-your-own)
42 | - [Contributing](#contributing)
43 | - [Roadmap](#roadmap)
44 | - [Star History](#star-history)
45 |
46 |
47 | ## Why this project
48 | I started this project after being inspired by this
49 | [video](https://youtu.be/_BsgckzDeRI?si=p18GIlR5urz-Pues).
50 |
51 | It was mid December and I had just gotten absolutely crushed
52 | in my first technical interview, so I wanted to build
53 | something simple to regain confidence.
54 |
55 |
56 | ## Features
57 | - Create Short-form media from UGC content from the worst website on the
58 | internet.
59 | - Upload videos directly to YouTube via the [YouTube
60 | API](https://developers.google.com/youtube/v3).
61 | - Automatically generate captions for your videos using Open AI
62 | [Whisper](https://github.com/openai/whisper).
63 | - Videos are highly customizable and render blazingly fast with FFMPEG.
64 | - Utilizes SQLite to avoid creating duplicate content.
65 |
66 |
67 | ## Installation
68 | Reddit Shorts can run independently but if you want to upload shorts
69 | automatically to YouTube or TikTok you must install the [TikTok
70 | Uploader](https://github.com/wkaisertexas/tiktok-uploader) and set up the
71 | [YouTube Data API](https://developers.google.com/youtube/v3) respectively.
72 |
73 | Additionally, install the reddit shorts repo in the root directory of the
74 | project as shown in the file tree.
75 |
76 | ### File tree
77 |
78 | ```
79 | ├── client_secrets.json
80 | ├── cookies.txt
81 | ├── [reddit-shorts]
82 | ├── resources
83 | │ ├── footage
84 | │ └── music
85 | ├── tiktok-uploader
86 | ├── tiktok_tts.txt
87 | └── youtube_tts.txt
88 | ```
89 |
90 | ### Optional
91 | - [TikTok Uploader](https://github.com/wkaisertexas/tiktok-uploader) is required to upload shorts to TikTok.
92 | - [YouTube Data API](https://developers.google.com/youtube/v3) is required to upload shorts to YouTube.
93 |
94 | ### Build source
95 | Because OpenAi's [Whisper](https://github.com/openai/whisper) is only compatible
96 | with Python 3.8 - 3.11 only use those versions of python.
97 |
98 | ```
99 | mkdir reddit-shorts
100 | gh repo clone gavink97/reddit-shorts-generator reddit-shorts
101 | pip install -e reddit-shorts
102 | ```
103 |
104 | ### Install dependencies
105 | This package requires `ffmpeg fonts-liberation` to be installed.
106 |
107 | *This repository utilizes pillow to create reddit images, so make sure to
108 | uninstall PIL if you have it installed*
109 |
110 | ### Config
111 | If your music / footage directory is different from the file tree configure the
112 | path inside `config.py`.
113 |
114 | If you are creating videos for TikTok or YouTube and wish to have some custom
115 | tts at the end of the video, make sure you
116 | create `tiktok_tts.txt` or `youtube_tts.txt`.
117 |
118 |
119 | ## Getting Started
120 | `shorts` is the command line interface for the project.
121 |
122 | Simply run `shorts -p platform` to generate a youtube short and automatically
123 | upload it.
124 |
125 | `shorts -p youtube`
126 |
127 | *it should be noted that the only supported platform is youtube at
128 | the moment*
129 |
130 |
131 | ## Making it your own
132 | Customize your shorts with FFMPG inside `create_short.py`.
133 |
134 |
135 | ## Contributing
136 | All contributions are welcome!
137 |
138 | ## Roadmap
139 |
140 | - [ ] Standalone video exports
141 | - [ ] TikTok Support
142 |
143 | ## Star History
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
--------------------------------------------------------------------------------
/reddit_shorts/get_reddit_stories.py:
--------------------------------------------------------------------------------
1 | import os
2 | import random
3 | import uuid # For generating unique IDs for stories
4 |
5 | # Removed praw, dotenv, and time imports as they are no longer needed for local file processing.
6 | # from dotenv import load_dotenv
7 | # from reddit_shorts.class_submission import Submission # We will create a simpler structure for now
8 | from reddit_shorts.config import stories_file_path, bad_words_list # music list might be used for type later
9 |
10 | # load_dotenv() # Not needed
11 | # reddit_client_id = os.environ['REDDIT_CLIENT_ID'] # Not needed
12 | # reddit_client_secret = os.environ['REDDIT_CLIENT_SECRET'] # Not needed
13 |
14 |
15 | # The Submission class might be too complex for local files initially.
16 | # We'll aim for a dictionary structure that create_short.py can use.
17 | # The original Submission class might have methods for cleaning, word checking, etc.
18 | # which we might need to replicate or simplify.
19 |
20 | def parse_stories_from_file(file_path: str) -> list[dict]:
21 | """Parses stories from the local text file."""
22 | stories = []
23 | try:
24 | with open(file_path, 'r', encoding='utf-8') as f:
25 | current_story = {}
26 | lines = f.readlines()
27 | idx = 0
28 | while idx < len(lines):
29 | line = lines[idx].strip()
30 | if line.startswith("Title:"):
31 | if current_story: # Save previous story before starting a new one
32 | # Basic validation: ensure story has title and body
33 | if current_story.get("title") and current_story.get("selftext"):
34 | stories.append(current_story)
35 | current_story = {} # Reset for next story
36 | current_story["title"] = line.replace("Title:", "", 1).strip()
37 | current_story["id"] = str(uuid.uuid4()) # Generate a unique ID
38 | current_story["subreddit"] = "local_story" # Placeholder
39 | current_story["url"] = f"local_story_url_{current_story['id']}" # Placeholder
40 | current_story["music_type"] = "general" # Default, can be refined
41 | # Try to infer music_type from title or story content if desired
42 | # For example:
43 | if "creepy" in current_story["title"].lower() or \
44 | (current_story.get("selftext") and "creepy" in current_story.get("selftext", "").lower()):
45 | current_story["music_type"] = "creepy"
46 | elif "story" in current_story["title"].lower() or \
47 | (current_story.get("selftext") and "story" in current_story.get("selftext", "").lower()):
48 | current_story["music_type"] = "storytime"
49 |
50 | elif line == "Story:" and "title" in current_story:
51 | idx += 1
52 | story_body_lines = []
53 | while idx < len(lines) and not lines[idx].strip().startswith("Title:"):
54 | story_body_lines.append(lines[idx].strip())
55 | idx += 1
56 | current_story["selftext"] = "\n".join(story_body_lines).strip()
57 | continue # Already incremented idx in the inner loop
58 |
59 | idx += 1
60 |
61 | if current_story and current_story.get("title") and current_story.get("selftext"): # Add the last story
62 | stories.append(current_story)
63 |
64 | except FileNotFoundError:
65 | print(f"Error: Stories file not found at {file_path}")
66 | return []
67 | except Exception as e:
68 | print(f"Error reading or parsing stories file: {e}")
69 | return []
70 | return stories
71 |
72 | def check_bad_words(text: str) -> bool:
73 | """Checks if the text contains any bad words from the configured list."""
74 | if not text:
75 | return False
76 | return any(bad_word in text.lower() for bad_word in bad_words_list)
77 |
78 | # Keep track of stories that have been processed in this session to avoid immediate reuse.
79 | # For persistent tracking, a database or file would be needed.
80 | _processed_story_ids_session = set()
81 |
82 | def get_story_from_file(**kwargs) -> dict | None:
83 | """Gets a single, unprocessed story from the local file."""
84 | # print("Getting a story from local file...") # Optional: for debugging
85 |
86 | all_stories = parse_stories_from_file(stories_file_path)
87 | if not all_stories:
88 | print("No stories found in the file or an error occurred.")
89 | return None
90 |
91 | available_stories = [s for s in all_stories if s["id"] not in _processed_story_ids_session]
92 |
93 | if not available_stories:
94 | print("All stories from the file have been processed in this session.")
95 | # Optionally, reset if all are processed and we want to loop:
96 | # _processed_story_ids_session.clear()
97 | # available_stories = all_stories
98 | # if not available_stories: return None
99 | return None # Or handle re-processing if desired
100 |
101 | selected_story = random.choice(available_stories)
102 |
103 | # Perform bad word check (simplified from original Submission class)
104 | if check_bad_words(selected_story.get("title", "")) or \
105 | check_bad_words(selected_story.get("selftext", "")):
106 | print(f"Story with title '{selected_story.get('title', '')}' contains bad words. Skipping.")
107 | _processed_story_ids_session.add(selected_story["id"]) # Mark as processed to avoid re-checking immediately
108 | return get_story_from_file(**kwargs) # Try to get another story
109 |
110 | _processed_story_ids_session.add(selected_story["id"])
111 |
112 | # The original Submission class had more processing, e.g. character limits,
113 | # database interaction. We are simplifying this for now.
114 | # The dictionary returned should be compatible with what create_short.py expects.
115 | # Essential fields based on original Submission.as_dict() and create_short.py:
116 | # - title (str)
117 | # - selftext (str)
118 | # - id (str)
119 | # - subreddit (str) -> we use "music_type" derived from story, or a placeholder
120 | # - url (str) - placeholder
121 | # - music_type (str) - derived or default "general"
122 |
123 | print(f"Selected story: '{selected_story.get('title', 'Untitled')}'")
124 | return selected_story
125 |
126 | # Removed connect_to_reddit and get_story_from_reddit functions.
127 | # The main script will now call get_story_from_file.
128 |
129 | if __name__ == '__main__':
130 | # For testing purposes
131 | story = get_story_from_file()
132 | if story:
133 | print("\nSelected story for testing:")
134 | print(f" ID: {story.get('id')}")
135 | print(f" Title: {story.get('title')}")
136 | print(f" Music Type: {story.get('music_type')}")
137 | print(f" Story: {story.get('selftext')[:100]}...") # Print first 100 chars of story
138 | else:
139 | print("No story selected for testing.")
140 |
141 | # Test processing all stories
142 | print("\n--- All Parsed Stories ---")
143 | all_s = parse_stories_from_file(stories_file_path)
144 | for s in all_s:
145 | print(f"Title: {s.get('title')}, ID: {s.get('id')}, Music: {s.get('music_type')}")
146 | print(f"Total stories parsed: {len(all_s)}")
147 | if not all_s and os.path.exists(stories_file_path):
148 | print(f"Make sure '{stories_file_path}' is not empty and stories are formatted correctly.")
149 |
--------------------------------------------------------------------------------
/web_ui/routes.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Blueprint, request, jsonify, send_file, send_from_directory
3 | from reddit_shorts.tiktok_voice.src.voice import Voice
4 | from reddit_shorts.main import run_local_video_generation
5 | from reddit_shorts.config import footage, music
6 |
7 | # Set the static folder when creating the blueprint
8 | # It should be relative to the blueprint's root path.
9 | # Since routes.py is in web_ui/, and static is also in web_ui/,
10 | # the path will be 'static'.
11 | main_bp = Blueprint('main', __name__, static_folder='static')
12 |
13 | # Voice name mapping based on user's provided table
14 | VOICE_NAME_MAP = {
15 | 'en_male_jomboy': 'Game On',
16 | 'en_us_002': 'Jessie',
17 | 'es_mx_002': 'Warm', # Note: Non-English, will be filtered by current logic unless changed
18 | 'en_male_funny': 'Wacky',
19 | 'en_us_ghostface': 'Scream', # Also 'Ghost Face' in user list, using 'Scream' as it's first
20 | 'en_female_samc': 'Empathetic',
21 | 'en_male_cody': 'Serious',
22 | 'en_female_makeup': 'Beauty Guru',
23 | 'en_female_richgirl': 'Bestie',
24 | 'en_male_grinch': 'Trickster',
25 | 'en_us_006': 'Joey',
26 | 'en_male_narration': 'Story Teller',
27 | 'en_male_deadpool': 'Mr. GoodGuy',
28 | 'en_uk_001': 'Narrator',
29 | 'en_uk_003': 'Male English UK',
30 | 'en_au_001': 'Metro',
31 | 'en_male_jarvis': 'Alfred',
32 | 'en_male_ashmagic': 'ashmagic',
33 | 'en_male_olantekkers': 'olantekkers',
34 | 'en_male_ukneighbor': 'Lord Cringe',
35 | 'en_male_ukbutler': 'Mr. Meticulous',
36 | 'en_female_shenna': 'Debutante',
37 | 'en_female_pansino': 'Varsity',
38 | 'en_male_trevor': 'Marty',
39 | 'en_female_f08_twinkle': 'Pop Lullaby',
40 | 'en_male_m03_classical': 'Classic Electric',
41 | 'en_female_betty': 'Bae',
42 | 'en_male_cupid': 'Cupid',
43 | 'en_female_grandma': 'Granny',
44 | 'en_male_m2_xhxs_m03_christmas': 'Cozy',
45 | 'en_male_santa_narration': 'Author',
46 | 'en_male_sing_deep_jingle': 'Caroler',
47 | 'en_male_santa_effect': 'Santa',
48 | 'en_female_ht_f08_newyear': 'NYE 2023',
49 | 'en_male_wizard': 'Magician',
50 | 'en_female_ht_f08_halloween': 'Opera',
51 | 'en_female_ht_f08_glorious': 'Euphoric',
52 | 'en_male_sing_funny_it_goes_up': 'Hypetrain',
53 | 'en_female_ht_f08_wonderful_world': 'Melodrama',
54 | 'en_male_m2_xhxs_m03_silly': 'Quirky Time',
55 | 'en_female_emotional': 'Peaceful',
56 | 'en_male_m03_sunshine_soon': 'Toon Beat',
57 | 'en_female_f08_warmy_breeze': 'Open Mic',
58 | 'en_male_sing_funny_thanksgiving': 'Thanksgiving',
59 | 'en_female_f08_salut_damour': 'Cottagecore',
60 | 'en_us_007': 'Professor',
61 | 'en_us_009': 'Scientist',
62 | 'en_us_010': 'Confidence',
63 | 'en_au_002': 'Smooth',
64 | # Duplicates from user table already covered: en_us_ghostface, en_us_chewbacca, etc.
65 | # Assuming codes from Voice enum like 'en_us_chewbacca' are preferred if not in mapping.
66 | 'fr_001': 'French - Male 1' # Added from user's list
67 | }
68 |
69 | # Prioritized voice codes from user
70 | PRIORITIZED_VOICE_CODES = [
71 | 'en_male_jomboy',
72 | 'en_us_002',
73 | 'es_mx_002',
74 | 'en_male_funny',
75 | 'en_us_ghostface',
76 | 'en_female_samc',
77 | 'en_male_cody',
78 | 'en_female_makeup',
79 | 'en_female_richgirl',
80 | 'en_male_grinch',
81 | 'en_us_006',
82 | 'en_male_narration',
83 | 'en_male_deadpool'
84 | ]
85 |
86 | @main_bp.route('/')
87 | def index():
88 | # Serves the index.html file from the 'static' directory
89 | # The directory is relative to the blueprint's static folder,
90 | # or app's static_folder if blueprint has no static_folder.
91 | # For our setup, Flask should find it in web_ui/static/
92 | return send_from_directory(main_bp.static_folder, 'index.html')
93 |
94 | @main_bp.route('/api/voices', methods=['GET'])
95 | def get_voices():
96 | """Return list of available TikTok voices, with prioritized voices first."""
97 | prioritized_list = []
98 | other_voices_list = []
99 |
100 | # Ensure all prioritized codes are valid and map them to their display info
101 | valid_prioritized_voice_objects = []
102 | for code in PRIORITIZED_VOICE_CODES:
103 | voice_enum_member = Voice((code)) # Get enum member by value (code)
104 | if voice_enum_member:
105 | # Apply filter (e.g. English only, or specific other languages)
106 | if voice_enum_member.value.startswith('en_') or voice_enum_member.value in ['fr_001', 'es_mx_002']:
107 | voice_name = VOICE_NAME_MAP.get(code, voice_enum_member.name.replace('_', ' ').title())
108 | prioritized_list.append({
109 | "id": code,
110 | "name": voice_name,
111 | "category": "TikTok"
112 | })
113 | valid_prioritized_voice_objects.append(voice_enum_member)
114 |
115 | # Process the rest of the voices from the Enum
116 | for voice_enum_member in Voice:
117 | if voice_enum_member not in valid_prioritized_voice_objects: # Avoid duplicates
118 | # Apply filter (e.g. English only, or specific other languages)
119 | if voice_enum_member.value.startswith('en_') or voice_enum_member.value in ['fr_001', 'es_mx_002']:
120 | voice_id = voice_enum_member.value
121 | voice_name = VOICE_NAME_MAP.get(voice_id, voice_enum_member.name.replace('_', ' ').title())
122 | other_voices_list.append({
123 | "id": voice_id,
124 | "name": voice_name,
125 | "category": "TikTok"
126 | })
127 |
128 | return jsonify(prioritized_list + other_voices_list)
129 |
130 | @main_bp.route('/api/backgrounds', methods=['GET'])
131 | def get_backgrounds():
132 | """Return list of available background videos"""
133 | if not footage:
134 | return jsonify([])
135 |
136 | backgrounds = []
137 | for video_path in footage:
138 | base_name = os.path.basename(video_path)
139 | video_name_without_ext = os.path.splitext(base_name)[0]
140 | thumbnail_url = f"/static/thumbnails/{video_name_without_ext}.jpg"
141 | backgrounds.append({
142 | "id": base_name,
143 | "name": video_name_without_ext,
144 | "path": video_path,
145 | "thumbnail": thumbnail_url # Add the thumbnail URL
146 | })
147 | return jsonify(backgrounds)
148 |
149 | @main_bp.route('/api/music', methods=['GET'])
150 | def get_music():
151 | """Return list of available music tracks"""
152 | if not music:
153 | return jsonify([])
154 |
155 | tracks = []
156 | for music_file_path, volume, music_type_from_config in music:
157 | base_name = os.path.basename(music_file_path)
158 | track_name = os.path.splitext(base_name)[0]
159 | # Construct a URL that the frontend can use to fetch the music for preview
160 | # Assumes music files are copied to 'web_ui/static/music_assets/'
161 | servable_url = f"/static/music_assets/{base_name}"
162 |
163 | tracks.append({
164 | "id": base_name, # Keep original ID for selection consistency if needed
165 | "name": track_name,
166 | "path": servable_url, # Send the servable URL
167 | "type": music_type_from_config # Use the type from config
168 | })
169 | return jsonify(tracks)
170 |
171 | @main_bp.route('/api/generate', methods=['POST'])
172 | def generate_video():
173 | """Generate a video from the provided script and settings"""
174 | data = request.json
175 |
176 | # Create a story entry in stories.txt format
177 | story_content = f"""Title: {data['title']}
178 | Story:
179 | {data['story']}
180 | """
181 |
182 | # Temporarily save the story
183 | with open('stories.txt', 'w', encoding='utf-8') as f:
184 | f.write(story_content)
185 |
186 | # Set up generation parameters
187 | params = {
188 | 'filter': data.get('filter', False),
189 | 'voice': data.get('voice', 'en_us_002'), # Default TikTok voice
190 | 'background_video': data.get('background_video', None),
191 | 'background_music': data.get('background_music', None)
192 | }
193 |
194 | try:
195 | # Generate the video
196 | video_path = run_local_video_generation(**params)
197 |
198 | if video_path and os.path.exists(video_path):
199 | # Return the video file
200 | return send_file(
201 | video_path,
202 | mimetype='video/mp4',
203 | as_attachment=True,
204 | download_name=os.path.basename(video_path)
205 | )
206 | else:
207 | return jsonify({"error": "Video generation failed"}), 500
208 |
209 | except Exception as e:
210 | return jsonify({"error": str(e)}), 500
--------------------------------------------------------------------------------
/reddit_shorts/class_submission.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any, Optional
3 | import re
4 | import os
5 | from praw.models import MoreComments
6 | from praw.models.comment_forest import CommentForest
7 | from reddit_shorts.config import project_path
8 | from reddit_shorts.query_db import check_if_video_exists, write_to_db, check_for_admin_posts
9 | from reddit_shorts.class_comment import Comment
10 | from reddit_shorts.utils import tts_for_platform, contains_bad_words
11 |
12 |
13 | class Submission:
14 | def __init__(self,
15 | subreddit: str,
16 | author: str,
17 | title: str,
18 | is_self: bool,
19 | text: str,
20 | url: str,
21 | score: int,
22 | num_comments: int,
23 | timestamp: datetime,
24 | id: int,
25 | comments: CommentForest,
26 | music_type: Optional[str],
27 | top_comment_body: Optional[str],
28 | top_comment_author: Optional[str],
29 | kind: str = None):
30 | self.subreddit = subreddit
31 | self.author = author
32 | self.title = title
33 | self.is_self = is_self
34 | self.text = text
35 | self.url = url
36 | self.score = score
37 | self.num_comments = num_comments
38 | self.timestamp = timestamp
39 | self.id = id
40 | self.comments = comments
41 | self.music_type = music_type
42 | self.top_comment_body = top_comment_body
43 | self.top_comment_author = top_comment_author
44 | self.kind = kind
45 |
46 | def as_dict(self):
47 | return {
48 | "subreddit": self.subreddit,
49 | "author": self.author,
50 | "title": self.title,
51 | "is_self": self.is_self,
52 | "text": self.text,
53 | "url": self.url,
54 | "score": self.score,
55 | "num_comments": self.num_comments,
56 | "timestamp": self.timestamp,
57 | "id": self.id,
58 | "comments": self.comments,
59 | "music_type": self.music_type,
60 | "top_comment_body": self.top_comment_body,
61 | "top_comment_author": self.top_comment_author,
62 | "kind": self.kind
63 | }
64 |
65 | @classmethod
66 | def process_submission(cls, subreddit: list, submission: Any, **kwargs) -> 'Submission':
67 | platform = kwargs.get('platform')
68 | (platform_tts_path, platform_tts) = tts_for_platform(**kwargs)
69 |
70 | subreddit_name = subreddit[0]
71 | subreddit_music_type = subreddit[1]
72 |
73 | tts_character_limit = 200
74 | min_character_len = 300
75 |
76 | if platform == 'youtube':
77 | max_character_len = 830
78 |
79 | elif platform == 'tiktok':
80 | max_character_len = 2400
81 |
82 | submission_author = str(submission.author)
83 | submission_title = str(submission.title)
84 | submission_text = str(submission.selftext)
85 | submission_id = str(submission.id)
86 | submission_kind = identify_post_type(submission)
87 |
88 | submission_author = submission_author.replace("-", "")
89 |
90 | qualify_submission(submission, **kwargs)
91 |
92 | suitable_submission = False
93 | comments = submission.comments
94 |
95 | for index, comment in enumerate(comments):
96 | if isinstance(comment, MoreComments):
97 | continue
98 |
99 | comment_data = Comment.process_comment(comment, submission_author)
100 | top_comment_body = comment_data.body
101 | top_comment_author = comment_data.author
102 | top_comment_id = comment_data.id
103 |
104 | total_length = len(submission_title) + len(submission_text) + len(top_comment_body) + len(platform_tts)
105 | print(f"{subreddit_name}:{submission_title} Total:{total_length}")
106 |
107 | if total_length < min_character_len:
108 | continue
109 |
110 | if total_length <= max_character_len:
111 | video_exists = check_if_video_exists(submission_id, top_comment_id)
112 | if video_exists is False:
113 | suitable_submission = True
114 | break
115 |
116 | else:
117 | continue
118 |
119 | if index == len(comments) - 1:
120 | print('reached the end of the comments')
121 | return None
122 |
123 | if suitable_submission is True:
124 | print("Found a suitable submission!")
125 | print(f"Suitable Submission:{subreddit}:{submission_title} Total:{total_length}")
126 |
127 | if not os.path.exists(f"{project_path}/temp/ttsoutput/texts/"):
128 | os.makedirs(f"{project_path}/temp/ttsoutput/texts/")
129 |
130 | if len(submission_title) >= tts_character_limit:
131 | with open(f'{project_path}/temp/ttsoutput/texts/{submission_author}_title.txt', 'w', encoding='utf-8') as file:
132 | file.write(submission_title)
133 |
134 | if len(submission_text) >= tts_character_limit:
135 | with open(f'{project_path}/temp/ttsoutput/texts/{submission_author}_content.txt', 'w', encoding='utf-8') as file:
136 | file.write(submission_text)
137 |
138 | if len(top_comment_body) >= tts_character_limit:
139 | with open(f'{project_path}/temp/ttsoutput/texts/{top_comment_author}.txt', 'w', encoding='utf-8') as file:
140 | file.write(top_comment_body)
141 |
142 | write_to_db(submission_id, top_comment_id)
143 |
144 | return cls(
145 | subreddit=subreddit_name,
146 | author=submission_author,
147 | title=submission_title,
148 | is_self=submission.is_self,
149 | text=submission_text,
150 | url=submission.url,
151 | score=submission.score,
152 | num_comments=submission.num_comments,
153 | timestamp=submission.created_utc,
154 | id=submission_id,
155 | comments=submission.comments,
156 | music_type=subreddit_music_type,
157 | top_comment_body=top_comment_body,
158 | top_comment_author=top_comment_author,
159 | kind=submission_kind
160 | )
161 |
162 |
163 | class Title(Submission):
164 | kind = "title"
165 |
166 |
167 | class Text(Submission):
168 | kind = "text"
169 |
170 |
171 | class Link(Submission):
172 | kind = "link"
173 |
174 |
175 | class Image(Submission):
176 | kind = "image"
177 |
178 |
179 | class Video(Submission):
180 | kind = "video"
181 |
182 |
183 | def identify_post_type(submission: Any) -> str:
184 | image_extensions = ['.jpg', '.jpeg', '.png', '.gif']
185 | video_extensions = ['.mp4', '.avi', '.mov']
186 | reddit_image_extensions = [
187 | 'https://www.reddit.com/gallery/',
188 | 'https://i.redd.it/'
189 | ]
190 | reddit_video_extensions = [
191 | 'https://v.redd.it/',
192 | 'https://youtube.com',
193 | 'https://youtu.be'
194 | ]
195 |
196 | if submission.is_self is True:
197 |
198 | if submission.selftext == "":
199 | return Title.kind
200 |
201 | else:
202 | return Text.kind
203 |
204 | if submission.is_self is False:
205 | url = submission.url
206 |
207 | if any(url.endswith(ext) for ext in image_extensions):
208 | return Image.kind
209 |
210 | elif any(url.startswith(ext) for ext in reddit_image_extensions):
211 | return Image.kind
212 |
213 | elif any(url.endswith(ext) for ext in video_extensions):
214 | return Video.kind
215 |
216 | elif any(url.startswith(ext) for ext in reddit_video_extensions):
217 | return Video.kind
218 |
219 | else:
220 | return Link.kind
221 |
222 |
223 | def qualify_submission(submission: Any, **kwargs) -> None:
224 | filter = kwargs.get('filter')
225 | url_pattern = re.compile(r'http', flags=re.IGNORECASE)
226 |
227 | submission_title = str(submission.title)
228 | submission_text = str(submission.selftext)
229 | submission_id = str(submission.id)
230 | submission_kind = identify_post_type(submission)
231 |
232 | if submission_kind == "image" or submission_kind == "video":
233 | # print(f"This submission is not in text {submission_title}")
234 | pass
235 |
236 | if url_pattern.search(submission_text.lower()):
237 | # print("Skipping post that contains a link")
238 | pass
239 |
240 | admin_post = check_for_admin_posts(submission_id)
241 | if admin_post is True:
242 | pass
243 |
244 | if filter is True:
245 | if contains_bad_words(submission_title):
246 | pass
247 |
248 | if contains_bad_words(submission_text):
249 | pass
250 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TikTok Brainrot Generator
2 |
3 | This project generates engaging short videos from text stories. It features a web-based UI for easy video creation, allowing users to input stories, select background videos, choose background music, and pick from a variety of TikTok-style Text-to-Speech (TTS) voices. The final videos are saved directly to your local machine.
4 |
5 | Created by [egebese](https://x.com/egebese).
6 |
7 | **Acknowledgements:** This project is a significantly modified version of the original [reddit-shorts-generator by gavink97](https://github.com/gavink97/reddit-shorts-generator.git). While the core functionality has been adapted for local use with different TTS and story input methods, and now includes a Web UI, much of the foundational video processing logic and project structure is derived from this original work.
8 |
9 | ## Key Features
10 |
11 | * **Web-Based User Interface:** Easily create videos through a user-friendly web page.
12 | * **Flexible Input:**
13 | * Enter story titles and content directly in the UI.
14 | * Select background videos from your `resources/footage/` directory, with thumbnail previews.
15 | * Choose background music from your `resources/music/` directory, with audio previews.
16 | * Select from a wide range of TikTok TTS voices.
17 | * **Local Video Output:** Saves generated videos to the `generated_shorts/` directory.
18 | * **TikTok TTS Narration:** Utilizes the [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library for dynamic and natural-sounding text-to-speech.
19 | * **Customizable Aesthetics:**
20 | * **Fonts:** Uses "Montserrat ExtraBold" for the title image text and video subtitles by default.
21 | * **Background Video Looping:** Background videos will loop if their duration is shorter than the narration.
22 | * **Custom Title Image:** Allows for a user-provided template (`resources/images/reddit_submission_template.png`) where the story title is overlaid.
23 | * **Platform Independent:** Core functionality does not rely on direct integration with external platforms like Reddit or YouTube APIs for content fetching or uploading.
24 |
25 | ## Setup and Installation
26 |
27 | ### Prerequisites
28 |
29 | 1. **Python:** Python 3.11+ is recommended. You can use tools like `pyenv` to manage Python versions.
30 | ```bash
31 | # Example using pyenv (if you have it installed)
32 | # pyenv install 3.11.9
33 | # pyenv local 3.11.9
34 | ```
35 | 2. **FFmpeg:** Essential for video processing. It must be installed on your system and accessible via your system's PATH.
36 | * **macOS (using Homebrew):** `brew install ffmpeg`
37 | * **Linux (using apt):** `sudo apt update && sudo apt install ffmpeg`
38 | * **Windows:** Download from the [FFmpeg website](https://ffmpeg.org/download.html) and add the `bin` directory to your PATH.
39 | 3. **Fonts:**
40 | * **Montserrat ExtraBold:** Used for title images and subtitles. Ensure this font is installed on your system. You can typically find and install `.ttf` or `.otf` files. If the font is not found by its name, you may need to modify the font paths/names directly in `reddit_shorts/make_submission_image.py` and `reddit_shorts/create_short.py`.
41 |
42 | ### Installation Steps
43 |
44 | 1. **Clone the Repository:**
45 | ```bash
46 | git clone https://github.com/egebese/tiktok-brainrot-generator.git # Or your fork
47 | cd tiktok-brainrot-generator
48 | ```
49 |
50 | 2. **Create and Activate a Python Virtual Environment:**
51 | ```bash
52 | python3 -m venv venv
53 | source venv/bin/activate # On Windows: venv\Scripts\activate
54 | ```
55 |
56 | 3. **Install Dependencies:**
57 | ```bash
58 | pip install -r requirements.txt
59 | ```
60 | If you intend to modify the core `reddit_shorts` package itself and want those changes reflected immediately (developer install):
61 | ```bash
62 | pip install -e .
63 | ```
64 |
65 | ## Project Structure & Required Assets
66 |
67 | Before running, ensure the following files and directories are set up:
68 |
69 | * **`resources/footage/`**:
70 | * Place your background MP4 video files in this directory. These will be available for selection in the Web UI. Thumbnails will be automatically generated and cached in `web_ui/static/thumbnails/`.
71 | * **`resources/music/`**:
72 | * Place your background music files (MP3, WAV, OGG) here. These will be available for selection in the Web UI. For preview functionality, ensure assets are accessible (e.g., copied to `web_ui/static/music_assets/` by the application or during setup).
73 | * **`resources/images/reddit_submission_template.png`**:
74 | * This is your custom background image for the title screen overlay. The story title will be drawn onto this image.
75 | * The current title placement is configured in `reddit_shorts/make_submission_image.py`. You may need to adjust these if you change the template significantly.
76 | * **`generated_shorts/`**:
77 | * This directory will be created automatically if it doesn't exist. All videos generated via the Web UI will be saved here.
78 | * **`web_ui/`**: Contains the Flask web application.
79 | * `static/`: Holds static assets for the UI (HTML, CSS, JS, and initially empty `thumbnails` and `music_assets` folders).
80 | * `routes.py`: Defines the API endpoints and serves the UI.
81 | * `__init__.py`: Initializes the Flask app.
82 | * **`run_web.py`**: Script to start the Flask development server for the Web UI.
83 | * **`reddit_shorts/`**: Main Python package containing the video generation logic.
84 | * `config.py`: Contains paths to resources, bad words list, and other configurations.
85 | * `main.py`: Core logic for video generation, called by the Web UI.
86 | * `make_tts.py`: Handles Text-to-Speech using the integrated TikTok TTS library.
87 | * `create_short.py`: Manages the FFmpeg video assembly process.
88 | * `make_submission_image.py`: Generates the title image overlay.
89 | * `tiktok_voice/`: The integrated [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library.
90 | * **`requirements.txt`**: Lists Python dependencies for the project, including Flask for the Web UI.
91 |
92 | ## Usage
93 |
94 | ### Running the Web UI
95 |
96 | 1. **Prepare Assets:**
97 | * Add your MP4 background videos to the `resources/footage/` directory.
98 | * Add your MP3/WAV/OGG music files to the `resources/music/` directory. (The UI will copy `music.mp3` to `web_ui/static/music_assets/` for preview if it exists at the expected location).
99 | * Ensure your `resources/images/reddit_submission_template.png` is in place.
100 | 2. **Start the Flask Server:**
101 | Open your terminal, navigate to the project's root directory, ensure your virtual environment is activated, and run:
102 | ```bash
103 | python run_web.py
104 | ```
105 | 3. **Access the UI:**
106 | Open your web browser and go to `http://127.0.0.1:5001` (or the port specified in the terminal output).
107 | 4. **Generate Videos:**
108 | * Fill in the "Title" and "Story" fields.
109 | * Select a background video from the displayed thumbnails.
110 | * Select background music and preview it.
111 | * Choose a TTS voice from the paginated table.
112 | * Click "Generate Video". The video will be processed and then downloaded by your browser. It will also be saved in the `generated_shorts/` directory.
113 |
114 | ### (Alternative) Original CLI Usage (Limited Functionality)
115 |
116 | The project previously supported a CLI-based generation using `stories.txt`. While the Web UI is now the primary method, the underlying CLI entry point `brainrot-gen` (or `python -m reddit_shorts.main`) might still work for basic generation if `stories.txt` is populated, but it will not use the UI's selection features for voice, specific background video, or music.
117 |
118 | For CLI usage with `stories.txt`:
119 | 1. **Prepare `stories.txt`**: (Create this file in the project root if using CLI)
120 | * Format: Each story should have a title and the story body:
121 | ```
122 | Title: [Your Story Title Here]
123 | Story:
124 | [First paragraph of your story]
125 | ...
126 | ```
127 | Separate multiple stories with at least one blank line.
128 | 2. **Run:**
129 | ```bash
130 | brainrot-gen
131 | # or
132 | # python -m reddit_shorts.main
133 | ```
134 | * `--filter`: Enables a basic profanity filter.
135 |
136 | ## Customization
137 |
138 | * **Video Editing Logic:** Modify `reddit_shorts/create_short.py` to change FFmpeg parameters, subtitle styles, or video composition.
139 | * **Title Image Generation:** Adjust title placement, font, or text wrapping in `reddit_shorts/make_submission_image.py`.
140 | * **TTS Voice Management:** Voices are sourced from the `tiktok_voice` library and managed in `web_ui/routes.py` for the UI.
141 | * **Configuration:** Edit `reddit_shorts/config.py` for resource paths, etc.
142 | * **Web UI Frontend:** Modify `web_ui/static/index.html` for UI layout, styling (Tailwind CSS), and Vue.js app logic.
143 | * **Web UI Backend:** Modify `web_ui/routes.py` for API endpoint behavior.
144 |
145 | ## Troubleshooting
146 |
147 | * **`ModuleNotFoundError: No module named 'flask'` (or other dependencies):** Ensure you've installed dependencies using `pip install -r requirements.txt`.
148 | * **`Address already in use` for port 5001:** Another application is using the port. You can change the port in `run_web.py`.
149 | * **404 Errors in UI / API calls not working:** Check terminal logs from `python run_web.py` for errors. Ensure Flask routes are correctly defined.
150 | * **`ffmpeg: command not found`**: Ensure FFmpeg is installed and in your system's PATH.
151 | * **Font errors (e.g., "Font not found")**: Make sure "Montserrat ExtraBold" (or your chosen font) is installed correctly.
152 | * **TTS Issues**:
153 | * Check your internet connection.
154 | * The underlying API used by the TTS library can sometimes be unreliable.
155 | * Look for error messages in the console output from `python run_web.py`.
156 | * **No videos generated / Video generation fails**:
157 | * Check `resources/footage/` has at least one `.mp4` file.
158 | * Check `resources/music/` has music files.
159 | * Ensure `resources/images/reddit_submission_template.png` exists.
160 | * Examine console output from `python run_web.py` for detailed FFmpeg or Python errors during generation.
161 |
162 | ## Acknowledgements
163 |
164 | * This project is a heavily modified fork of [gavink97/reddit-shorts-generator](https://github.com/gavink97/reddit-shorts-generator).
165 | * Uses the [mark-rez/TikTok-Voice-TTS](https://github.com/mark-rez/TikTok-Voice-TTS) library.
166 | * Web UI uses Vue.js and Tailwind CSS.
167 |
168 | ## License
169 |
170 | This project is currently unlicensed, inheriting the original [MIT License](https://github.com/gavink97/reddit-shorts-generator/blob/main/LICENSE) from the upstream repository where applicable to original code sections. New modifications by egebese are also effectively under MIT-style permissions unless otherwise specified.
171 |
172 | ---
173 |
174 | *This project builds upon the foundational structure and concepts from the [Reddit Shorts Generator by gavink97](https://github.com/gavink97/reddit-shorts-generator.git).*
175 |
176 | ## Star History
177 |
178 | [](https://www.star-history.com/#egebese/brainrot-generator&Date)
--------------------------------------------------------------------------------
/reddit_shorts/make_submission_image.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import os
3 | import random # For placeholder data
4 |
5 | from PIL import Image, ImageFont, ImageDraw, ImageOps # ImageOps for potential padding
6 |
7 | from reddit_shorts.config import project_path # This should be default_project_path now or ensure it resolves correctly
8 | # Removed unused utils: split_string_at_space, abbreviate_number, format_relative_time
9 | # as we will simplify the image to mostly title, or use the template's existing elements.
10 |
11 | # Helper function for text wrapping (can be improved)
12 | def wrap_text(text, font, max_width):
13 | lines = []
14 | if not text:
15 | return lines
16 |
17 | words = text.split()
18 | current_line = ''
19 | for word in words:
20 | # Test if adding word exceeds max_width
21 | # For multiline_textbbox, we'd need a draw object, but font.getlength is for single line
22 | # A simple approximation: test bbox of current_line + word
23 | # More accurately, build line by line and measure with draw.multiline_textbbox
24 | if current_line: # if line is not empty
25 | test_line = f'{current_line} {word}'
26 | else:
27 | test_line = word
28 |
29 | # Use textbbox for more accurate width, considering font specifics
30 | # Requires a ImageDraw object, so we pass it or create a dummy one if needed for helper
31 | # For simplicity here, let's assume a draw object would be available or use getlength as an approximation
32 | # Note: getlength is for single line. For multiline, textbbox is better.
33 | # Pillow versions differ: older getsize, newer getlength, textbbox, multiline_textbbox
34 | try:
35 | # Try to get width using textlength if available (newer Pillow)
36 | line_width = font.getlength(test_line)
37 | except AttributeError:
38 | # Fallback for older Pillow or if no draw object for textbbox
39 | # This is a rough estimate based on average char width, not ideal.
40 | # A better fallback for older Pillow is draw.textsize(test_line, font=font)[0]
41 | # but this helper doesn't have `draw` yet.
42 | # For a robust solution, this helper would need `draw` or be part of a class.
43 | # For now, we keep it simple and expect it might need refinement.
44 | # A very basic character count based splitting if font metrics fail badly:
45 | # if len(test_line) * (font.size * 0.6) > max_width: # Rough estimate
46 | # For now, let's stick to what original code implies (word splitting by count)
47 | # The original code had `characters_to_linebreak` which is what we are trying to replace
48 | # with a width-based approach.
49 | pass # Placeholder for more robust width check if font.getlength fails
50 |
51 | if font.getlength(test_line) <= max_width:
52 | current_line = test_line
53 | else:
54 | if current_line: # Avoid adding empty lines if a single word is too long
55 | lines.append(current_line)
56 | current_line = word
57 | # Handle case where a single word is longer than max_width (optional: break word)
58 | if font.getlength(current_line) > max_width:
59 | # For now, just add it if it's the only word, it will overflow.
60 | # Proper handling would be char-level splitting.
61 | lines.append(current_line)
62 | current_line = '' # Reset if word itself was added
63 |
64 | if current_line: # Add the last line
65 | lines.append(current_line)
66 | return lines
67 |
68 | def generate_reddit_story_image(**kwargs) -> None:
69 | story_id = str(kwargs.get('id', 'unknown_story')) # Use ID for filename, provide fallback
70 | # subreddit will be music_type or 'local_story' passed from main.py
71 | subreddit = str(kwargs.get('subreddit', 'local_story'))
72 | submission_title = str(kwargs.get('title', 'Untitled Story'))
73 |
74 | # Provide placeholders for Reddit-specific data not available from local files
75 | submission_author = str(kwargs.get('author', story_id[:20])) # Use truncated story_id or a generic name
76 | submission_timestamp = kwargs.get('timestamp', datetime.datetime.now().timestamp())
77 | submission_score = int(kwargs.get('score', random.randint(20, 300)))
78 | submission_comments_int = int(kwargs.get('num_comments', random.randint(5, 50)))
79 |
80 | subreddit_lowercase = subreddit.lower()
81 |
82 | # Ensure temp/images directory exists
83 | temp_images_dir = os.path.join(project_path, "temp", "images")
84 | os.makedirs(temp_images_dir, exist_ok=True)
85 |
86 | story_template_path = os.path.join(project_path, "resources", "images", "reddit_submission_template.png")
87 | if not os.path.exists(story_template_path):
88 | print(f"Error: Story template not found at {story_template_path}")
89 | return
90 | story_template = Image.open(story_template_path).convert("RGBA") # Ensure RGBA for transparency handling
91 | template_width, template_height = story_template.size
92 |
93 | # Define offsets for the title box based on user specification
94 | offset_left = 148
95 | offset_top = 198
96 | offset_right = 148
97 | offset_bottom = 134
98 |
99 | # Calculate title box coordinates
100 | title_box_left = offset_left
101 | title_box_top = offset_top
102 | title_box_right = template_width - offset_right
103 | title_box_bottom = template_height - offset_bottom
104 | title_box_width = title_box_right - title_box_left
105 | title_box_height = title_box_bottom - title_box_top # Calculated for completeness
106 |
107 | if title_box_width <= 0 or title_box_height <= 0:
108 | print(f"Error: Calculated title box dimensions are invalid (width: {title_box_width}, height: {title_box_height}). Check template size and offsets.")
109 | return
110 |
111 | # Output filename uses story_id
112 | submission_image_path = os.path.join(temp_images_dir, f"{story_id}.png")
113 |
114 | community_logo_path = os.path.join(project_path, "resources", "images", "subreddits", f"{subreddit_lowercase}.png")
115 | default_community_logo_path = os.path.join(project_path, "resources", "images", "subreddits", "default.png")
116 |
117 | if len(submission_author) > 22:
118 | submission_author_formatted = submission_author[:22]
119 | # return submission_author_formatted # This was an early return, should not be here
120 | else:
121 | submission_author_formatted = submission_author
122 |
123 | font_name = "Montserrat-ExtraBold" # Changed from LiberationSans-Bold
124 | # Start with a reasonable font size; this might need to be dynamic later.
125 | # For dynamic font sizing, you would loop, check fit, and adjust size.
126 | title_font_size = 60
127 | try:
128 | title_font = ImageFont.truetype(font_name, title_font_size)
129 | except IOError:
130 | print(f"Error: Font '{font_name}' at size {title_font_size} not found. Please ensure Montserrat ExtraBold is installed and accessible.")
131 | # Attempt a very basic fallback if the specified font isn't found
132 | try:
133 | title_font = ImageFont.load_default() # Very basic, might not look good
134 | print("Warning: Using default Pillow font due to error with Montserrat-ExtraBold.")
135 | except Exception as e_font_fallback:
136 | print(f"Error loading default font: {e_font_fallback}. Cannot draw title.")
137 | return
138 |
139 | draw = ImageDraw.Draw(story_template)
140 |
141 | if os.path.exists(community_logo_path):
142 | community_logo_img = Image.open(community_logo_path)
143 | elif os.path.exists(default_community_logo_path):
144 | community_logo_img = Image.open(default_community_logo_path)
145 | else:
146 | print(f"Warning: Default community logo not found at {default_community_logo_path}. Skipping logo.")
147 | community_logo_img = None
148 |
149 | if community_logo_img:
150 | community_logo_img = community_logo_img.resize((244, 244))
151 | story_template.paste(community_logo_img, (222, 368), mask=community_logo_img if community_logo_img.mode == 'RGBA' else None)
152 |
153 | # --- Text Wrapping and Drawing for Title ---
154 | # Using a simpler wrap_text logic for now. Pillow's TextWrapper is not in older versions.
155 | # For complex cases, a more sophisticated text engine or manual line breaking based on draw.textbbox is better.
156 |
157 | # Simple line wrapper (based on character count per line, then join - from original code)
158 | # This is less accurate than width-based wrapping. We'll try to improve.
159 | # characters_to_linebreak = int(title_box_width / (title_font_size * 0.45)) # Rough estimate of chars per line
160 | # if characters_to_linebreak == 0: characters_to_linebreak = 10 # Avoid zero div / too small
161 | # chunks = []
162 | # current_line_start = 0
163 | # while current_line_start < len(submission_title):
164 | # line_end = current_line_start + characters_to_linebreak
165 | # if line_end < len(submission_title):
166 | # # Try to break at a space
167 | # actual_line_end = submission_title.rfind(' ', current_line_start, line_end + 1)
168 | # if actual_line_end == -1 or actual_line_end < current_line_start: # No space found, or space is before start
169 | # actual_line_end = line_end # Break mid-word if no space
170 | # else:
171 | # actual_line_end = len(submission_title)
172 | # chunks.append(submission_title[current_line_start:actual_line_end].strip())
173 | # current_line_start = actual_line_end + 1 # Skip the space if broken at space
174 | # wrapped_title_text = '\n'.join(chunks)
175 |
176 | # Improved wrapping using the helper (still basic, assumes `font.getlength` works)
177 | wrapped_lines = wrap_text(submission_title, title_font, title_box_width)
178 | wrapped_title_text = '\n'.join(wrapped_lines)
179 |
180 | # If you have Pillow 9.2.0+ you can use multiline_textbbox for better vertical centering and fit checking.
181 | # For now, we draw at top of box and rely on fixed line spacing.
182 | # Anchor 'lt' means top-left for the text block.
183 | # To center within the box, more calculations are needed (get text block height, then offset top).
184 |
185 | # Get the bounding box of the wrapped text to help with centering (optional, but good for alignment)
186 | try:
187 | # text_bbox for Pillow 9.2.0+
188 | # For older versions, this specific call might not exist or behave differently.
189 | # draw.multiline_textbbox((0,0), wrapped_title_text, font=title_font, spacing=4) # spacing is line spacing
190 | # If using an older Pillow, one might have to render to a dummy image to get size or sum line heights.
191 | # For now, let's use a default spacing and align top-left in the box.
192 | text_x = title_box_left
193 | text_y = title_box_top
194 | line_spacing = 10 # Adjust as needed for the chosen font size
195 |
196 | # Vertical centering: (needs text block height)
197 | #_, _, _, text_block_bottom = draw.multiline_textbbox((text_x, text_y), wrapped_title_text, font=title_font, spacing=line_spacing)
198 | #text_block_height = text_block_bottom - text_y # This is not quite right, text_y is not 0 for bbox calc relative to (0,0)
199 | # A better way: sum heights or use bbox with (0,0) as origin
200 | # For simplicity, let's just draw from the top of the box for now and adjust line spacing.
201 | # True vertical centering within the box title_box_height would involve:
202 | # 1. Get total height of wrapped_title_text with chosen font and spacing.
203 | # 2. text_y = title_box_top + (title_box_height - total_text_height) / 2
204 |
205 | draw.multiline_text(
206 | (text_x, text_y),
207 | wrapped_title_text,
208 | fill=(35, 31, 32, 255), # Black, fully opaque
209 | font=title_font,
210 | spacing=line_spacing, # Line spacing
211 | align='left' # 'left', 'center', or 'right' (horizontal alignment of lines)
212 | )
213 | print(f"Title drawn in box ({title_box_left},{title_box_top})-({title_box_right},{title_box_bottom}) with font size {title_font_size}.")
214 |
215 | except Exception as e_draw:
216 | print(f"Error drawing multiline text for title: {e_draw}")
217 |
218 | # Since the template now provides all other UI elements (r/AITA, scores, etc.),
219 | # we remove the script's logic for drawing those.
220 | # The original code for drawing author, timestamp, scores, community logo is now removed.
221 |
222 | try:
223 | story_template.save(submission_image_path)
224 | print(f"Submission image saved to: {submission_image_path}")
225 | except Exception as e:
226 | print(f"Error saving submission image: {e}")
227 |
228 | # story_template.show() # Keep commented out unless debugging
229 |
--------------------------------------------------------------------------------
/reddit_shorts/make_tts.py:
--------------------------------------------------------------------------------
1 | import os
2 | # import ssl # No longer needed if not using tiktok_tts which might have its own ssl context needs
3 |
4 | # from dotenv import load_dotenv # Not needed for gTTS
5 | from gtts import gTTS
6 | import shutil # For ensuring temp directories exist
7 |
8 | # from reddit_shorts.config import project_path # We'll use a passed-in temp_dir
9 | # from reddit_shorts.tiktok_tts import tts # Replacing this
10 | # from reddit_shorts.utils import tts_for_platform # Replacing this logic
11 |
12 | # ssl._create_default_https_context = ssl._create_unverified_context # Likely not needed for gTTS
13 |
14 | # load_dotenv() # Not needed
15 | # tiktok_session_id = os.environ['TIKTOK_SESSION_ID_TTS'] # Not needed
16 |
17 | # Import the tts function from the existing tiktok_tts module
18 | # from reddit_shorts.tiktok_tts import tts as tiktok_tts_api
19 |
20 | # Import from the new library copied into reddit_shorts/tiktok_voice
21 | # The actual tts function and Voice enum are in tiktok_voice.src
22 | from reddit_shorts.tiktok_voice.src.text_to_speech import tts as tiktok_library_tts
23 | from reddit_shorts.tiktok_voice.src.voice import Voice
24 |
25 | # Default voice mapping to the new library's enum
26 | # Voice.US_FEMALE_2 maps to 'en_us_002'
27 | DEFAULT_TIKTOK_VOICE_ENUM = Voice.US_FEMALE_2
28 |
29 | def generate_gtts_for_story(title: str, text_content: str, story_id: str, temp_dir: str) -> dict:
30 | """
31 | Generates TTS audio for title and content using gTTS and saves them to the specified temp_dir.
32 | Returns a dictionary with paths to the generated TTS files.
33 | """
34 | if not title and not text_content:
35 | print("Error: Both title and text content are empty. Cannot generate TTS.")
36 | return {'title_tts_path': None, 'content_tts_path': None}
37 |
38 | # Ensure the specific temporary directory for this story's TTS exists
39 | # temp_dir is expected to be something like /path/to/project/temp//
40 | os.makedirs(temp_dir, exist_ok=True)
41 |
42 | title_tts_path = None
43 | content_tts_path = None
44 | lang = 'en' # Language for TTS
45 |
46 | try:
47 | # TTS for Title
48 | if title:
49 | title_tts_filename = f"title_{story_id}.mp3"
50 | title_tts_path = os.path.join(temp_dir, title_tts_filename)
51 | tts_obj = gTTS(text=title, lang=lang, slow=False)
52 | tts_obj.save(title_tts_path)
53 | print(f"Title TTS generated: {title_tts_path}")
54 | else:
55 | print("Title is empty, skipping title TTS generation.")
56 |
57 | # TTS for Content
58 | if text_content:
59 | # gTTS might have issues with very long texts in one go.
60 | # It's better to split if necessary, but for now, let's try direct.
61 | # Max length for gTTS is not strictly defined but practically, very long texts can fail or be slow.
62 | # Consider splitting text_content into chunks if issues arise.
63 | if len(text_content) > 4000: # Arbitrary limit, gTTS docs don't specify hard limit
64 | print(f"Warning: Content text is very long ({len(text_content)} chars). TTS might be slow or fail.")
65 |
66 | content_tts_filename = f"content_{story_id}.mp3"
67 | content_tts_path = os.path.join(temp_dir, content_tts_filename)
68 | tts_obj = gTTS(text=text_content, lang=lang, slow=False)
69 | tts_obj.save(content_tts_path)
70 | print(f"Content TTS generated: {content_tts_path}")
71 | else:
72 | print("Content text is empty, skipping content TTS generation.")
73 |
74 | except Exception as e:
75 | print(f"Error during gTTS generation: {e}")
76 | # Return None for paths if an error occurs to prevent downstream issues
77 | # It's possible one succeeded and the other failed.
78 | if title_tts_path and not os.path.exists(title_tts_path):
79 | title_tts_path = None
80 | if content_tts_path and not os.path.exists(content_tts_path):
81 | content_tts_path = None
82 | # No need to return partial success, create_short can handle missing files by skipping them.
83 |
84 | return {
85 | 'video_tts_path': title_tts_path, # Matching key expected by create_short_video (originally narrator_title_track)
86 | 'content_tts_path': content_tts_path # Matching key expected by create_short_video (originally narrator_content_track)
87 | }
88 |
89 | def generate_tiktok_tts_for_story(title: str, text_content: str, story_id: str, temp_dir: str, **kwargs) -> dict:
90 | """
91 | Generates TTS audio for title and content using the mark-rez/TikTok-Voice-TTS library
92 | and saves them to the specified temp_dir.
93 | Accepts an optional 'voice' kwarg for the voice code (e.g., 'en_us_002').
94 | Returns a dictionary with paths to the generated TTS files.
95 | """
96 | generated_paths = {'video_tts_path': None, 'content_tts_path': None}
97 | selected_voice_code = kwargs.get('voice', None)
98 |
99 | active_voice_enum = DEFAULT_TIKTOK_VOICE_ENUM
100 | if selected_voice_code:
101 | try:
102 | # Attempt to get the Voice enum member by its value (the voice code string)
103 | active_voice_enum = Voice(selected_voice_code)
104 | print(f"Using selected voice: {selected_voice_code} ({active_voice_enum.name})")
105 | except ValueError:
106 | print(f"Warning: Invalid voice code '{selected_voice_code}' provided. Falling back to default voice {DEFAULT_TIKTOK_VOICE_ENUM.value}.")
107 | # active_voice_enum remains DEFAULT_TIKTOK_VOICE_ENUM
108 |
109 | if not title and not text_content:
110 | print("Error: Both title and text content are empty. Cannot generate TTS.")
111 | return generated_paths
112 |
113 | # Ensure the temporary directory for this story exists
114 | os.makedirs(temp_dir, exist_ok=True)
115 |
116 | # --- Generate TTS for Title ---
117 | if title:
118 | title_tts_filename = f"title_{story_id}.mp3"
119 | title_tts_path = os.path.join(temp_dir, title_tts_filename)
120 | print(f"Generating TikTok TTS for title using new library: {title[:50]}...")
121 | try:
122 | tiktok_library_tts(
123 | text=title,
124 | voice=active_voice_enum,
125 | output_file_path=title_tts_path,
126 | play_sound=False
127 | )
128 | if os.path.exists(title_tts_path) and os.path.getsize(title_tts_path) > 0:
129 | generated_paths['video_tts_path'] = title_tts_path
130 | print(f"Title TTS successfully generated: {title_tts_path}")
131 | else:
132 | print(f"Error: Title TTS file not generated or empty by new library. Path: {title_tts_path}")
133 | except Exception as e:
134 | print(f"Error during title TTS generation with new library: {e}")
135 | import traceback
136 | traceback.print_exc()
137 | else:
138 | print("Title is empty, skipping title TTS generation.")
139 |
140 | # --- Generate TTS for Content ---
141 | if text_content:
142 | content_tts_filename = f"content_{story_id}.mp3"
143 | content_tts_path = os.path.join(temp_dir, content_tts_filename)
144 | print(f"Generating TikTok TTS for content using new library (first 50 chars): {text_content[:50]}...")
145 | try:
146 | # For content, the library handles splitting long text internally
147 | tiktok_library_tts(
148 | text=text_content,
149 | voice=active_voice_enum,
150 | output_file_path=content_tts_path,
151 | play_sound=False
152 | )
153 | if os.path.exists(content_tts_path) and os.path.getsize(content_tts_path) > 0:
154 | generated_paths['content_tts_path'] = content_tts_path
155 | print(f"Content TTS successfully generated: {content_tts_path}")
156 | else:
157 | print(f"Error: Content TTS file not generated or empty by new library. Path: {content_tts_path}")
158 | except Exception as e:
159 | print(f"Error during content TTS generation with new library: {e}")
160 | import traceback
161 | traceback.print_exc()
162 | else:
163 | print("Text content is empty, skipping content TTS generation.")
164 |
165 | return generated_paths
166 |
167 | # Example usage (for testing this file directly)
168 | if __name__ == '__main__':
169 | print("Testing gTTS generation...")
170 | # Create a dummy temp directory for testing
171 | test_story_id = "test_gtts_001"
172 | # project_root_for_test = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
173 | # test_temp_dir = os.path.join(project_root_for_test, "temp", test_story_id, "tts")
174 |
175 | # Simpler temp dir for direct script test relative to script location
176 | script_dir = os.path.dirname(os.path.abspath(__file__))
177 | test_temp_root = os.path.join(script_dir, "..", "temp") # e.g., project_root/temp/
178 | test_temp_story_dir = os.path.join(test_temp_root, test_story_id) # e.g., project_root/temp/test_gtts_001/
179 |
180 | # Ensure base temp directory exists (temp/)
181 | # os.makedirs(os.path.dirname(test_temp_dir), exist_ok=True)
182 | # os.makedirs(test_temp_dir, exist_ok=True)
183 | # This generate_gtts_for_story will create the final subdir if needed
184 |
185 | print(f"TTS files will be saved in a subdirectory of: {test_temp_story_dir}")
186 |
187 | sample_title = "Hello World from gTTS"
188 | sample_content = "This is a test of the Google Text-to-Speech library. It should generate an MP3 file with this text spoken."
189 |
190 | tts_paths = generate_gtts_for_story(sample_title, sample_content, test_story_id, test_temp_story_dir)
191 |
192 | if tts_paths.get('video_tts_path') and os.path.exists(tts_paths['video_tts_path']):
193 | print(f"Title TTS successfully created at: {tts_paths['video_tts_path']}")
194 | else:
195 | print("Title TTS creation failed or file not found.")
196 |
197 | if tts_paths.get('content_tts_path') and os.path.exists(tts_paths['content_tts_path']):
198 | print(f"Content TTS successfully created at: {tts_paths['content_tts_path']}")
199 | else:
200 | print("Content TTS creation failed or file not found.")
201 |
202 | # Clean up dummy test directory if you want
203 | # if os.path.exists(test_temp_dir):
204 | # shutil.rmtree(test_temp_dir)
205 | # print(f"Cleaned up test directory: {test_temp_dir}")
206 | # if os.path.exists(os.path.dirname(test_temp_dir)) and not os.listdir(os.path.dirname(test_temp_dir)):
207 | # os.rmdir(os.path.dirname(test_temp_dir))
208 | # if os.path.exists(os.path.dirname(os.path.dirname(test_temp_dir))) and not os.listdir(os.path.dirname(os.path.dirname(test_temp_dir))):
209 | # os.rmdir(os.path.dirname(os.path.dirname(test_temp_dir))) # clean up temp/ if empty
210 | print(f"If successful, check the subdirectory within {test_temp_story_dir} for MP3 files.")
211 |
212 | print("Testing new generate_tiktok_tts_for_story function...")
213 |
214 | # Create a dummy temp directory for testing
215 | test_story_id = "test_story_001"
216 | test_temp_dir_base = "temp" # Assuming script is run from project root where 'temp' would be
217 | test_temp_dir_story = os.path.join(test_temp_dir_base, test_story_id, "tts_test_output") # More specific path
218 |
219 | # Ensure the base temp directory exists for the test
220 | if not os.path.exists(test_temp_dir_base):
221 | os.makedirs(test_temp_dir_base)
222 | if not os.path.exists(os.path.join(test_temp_dir_base, test_story_id)):
223 | os.makedirs(os.path.join(test_temp_dir_base, test_story_id))
224 |
225 |
226 | print(f"Test temporary directory will be: {test_temp_dir_story}")
227 | # Clean up previous test directory if it exists
228 | if os.path.exists(test_temp_dir_story):
229 | shutil.rmtree(test_temp_dir_story)
230 | os.makedirs(test_temp_dir_story, exist_ok=True)
231 |
232 | sample_title = "Hello World from New Library"
233 | sample_content = "This is a test of the newly integrated TikTok TTS library. It should handle this text and save it as an MP3 audio file."
234 |
235 | results = generate_tiktok_tts_for_story(
236 | title=sample_title,
237 | text_content=sample_content,
238 | story_id=test_story_id,
239 | temp_dir=test_temp_dir_story
240 | )
241 |
242 | print("\n--- Test Results ---")
243 | if results.get('video_tts_path') and os.path.exists(results['video_tts_path']):
244 | print(f"Title TTS Path: {results['video_tts_path']} (Exists: True, Size: {os.path.getsize(results['video_tts_path'])})")
245 | else:
246 | print(f"Title TTS generation FAILED. Path: {results.get('video_tts_path')}")
247 |
248 | if results.get('content_tts_path') and os.path.exists(results['content_tts_path']):
249 | print(f"Content TTS Path: {results['content_tts_path']} (Exists: True, Size: {os.path.getsize(results['content_tts_path'])})")
250 | else:
251 | print(f"Content TTS generation FAILED. Path: {results.get('content_tts_path')}")
252 |
253 | # Suggest cleanup
254 | print(f"\nTest files were saved in: {test_temp_dir_story}")
255 | print("You may want to manually delete this directory after inspection.")
256 | # For automated cleanup, you could add:
257 | # if os.path.exists(test_temp_dir_story):
258 | # shutil.rmtree(test_temp_dir_story)
259 | # print(f"Cleaned up test directory: {test_temp_dir_story}")
260 |
--------------------------------------------------------------------------------
/web_ui/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Video Generator
7 |
8 |
9 |
10 |
11 |
12 |
13 |
Let's configure your video
14 |
15 |
16 |
17 |
Your Video Script
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Number of words: {{ wordCount }}
29 |
30 | Estimated video duration: {{ estimatedDuration }} seconds
31 |
32 |
33 |
34 |
35 |
36 |
37 |
Select a Background Video
38 |
39 |
44 |
45 |
46 |
48 |
49 |
{{ video.name }}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
Select Voice
59 |
60 |
61 |
62 |
63 |
Name
64 |
Code
65 |
Category
66 |
Preview
67 |
Action
68 |
69 |
70 |
71 |
{/* Slightly different bg for selected row */}
73 |