├── src ├── __init__.py ├── audio │ ├── __init__.py │ ├── concate_audio.py │ └── audio_utils.py ├── images │ ├── __init__.py │ └── thumbnail.py ├── test │ ├── __init__.py │ └── test_audio.py ├── utils │ ├── __init__.py │ ├── reddit_api.py │ ├── text_utils.py │ ├── upload_video.py │ ├── play_ht_api.py │ ├── generate_subtitles.py │ └── utils.py ├── video │ ├── __init__.py │ ├── utils.py │ ├── concatenate_videos.py │ ├── random_sample_clip.py │ ├── create_video_from_single_image.py │ └── cut_video.py ├── youtube │ └── __init__.py └── metadata │ ├── data_collection.py │ ├── get_youtube_video_tags.py │ ├── text_clustering_and_keyword_extraction.py │ ├── check_channel_monitization.py │ ├── __init__.py │ ├── youtube_search.py │ ├── keyword_analysis.py │ └── video_engagement_metrics.py ├── readme_assets └── reddit_api_example.PNG ├── example.env ├── .gitignore ├── .github └── workflows │ └── pylint.yml ├── main.py ├── gui_upload_video.py ├── examples └── reddit_to_video.py ├── requirements.txt ├── README.md └── LICENSE.txt /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/audio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/video/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/youtube/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /readme_assets/reddit_api_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isayahc/Semi-Automated-Youtube-Channel/HEAD/readme_assets/reddit_api_example.PNG -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | PROJECT_CX= 2 | GOOGLE_API_KEY= 3 | PLAYHT_API_KEY= 4 | PLAYHT_API_USER_ID= 5 | REDDIT_CLIENT_ID= 6 | REDDIT_CLIENT_SECRET= 7 | ELEVENLABS_API_KEY= 8 | STRING_AUDIO_FILE_LOCATION=assets/audio/posts 9 | SWEAR_WORD_LIST_FILE_LOCATION_FILE_LOCATION=assets/text/swear_words.csv 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore files and directories for Git 2 | .env 3 | client_secret.json 4 | /venv 5 | 6 | # Ignore directories not meant for GitHub 7 | /not_for_github 8 | /Roboto 9 | /reddit 10 | 11 | # Ignore Python cache directories 12 | **/__pycache__/ 13 | **/pytest_cache 14 | 15 | # Ignore Visual Studio Code settings 16 | /.vscode 17 | 18 | -------------------------------------------------------------------------------- /src/video/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from moviepy.editor import VideoFileClip 3 | 4 | def get_video_size(filename: str) -> Tuple[int, int]: 5 | """ 6 | Get the dimensions (width and height) of a video file. 7 | 8 | Args: 9 | filename (str): The path to the video file. 10 | 11 | Returns: 12 | Tuple[int, int]: A tuple containing the width and height of the video, in the format (width, height). 13 | """ 14 | video = VideoFileClip(filename) 15 | return (video.w, video.h) 16 | -------------------------------------------------------------------------------- /src/metadata/data_collection.py: -------------------------------------------------------------------------------- 1 | from metadata.get_youtube_video_tags import get_video_tags 2 | from metadata.video_engagement_metrics import get_video_engagement_metrics 3 | 4 | def collect_data(video_id, api_key): 5 | # Collect metadata about the video 6 | tags = get_video_tags(video_id, api_key) 7 | engagement_metrics = get_video_engagement_metrics(video_id, api_key) 8 | 9 | # Combine the tags and engagement metrics into one dictionary 10 | data = {**tags, **engagement_metrics} 11 | 12 | return data 13 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | - name: Analysing the code with pylint 22 | run: | 23 | pylint $(git ls-files '*.py') 24 | -------------------------------------------------------------------------------- /src/metadata/get_youtube_video_tags.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from YoutubeTags import videotags 3 | 4 | def get_video_tags(video_url: str) -> list: 5 | """Get a list of tags for a YouTube video. 6 | 7 | Args: 8 | video_url (str): URL of the YouTube video. 9 | 10 | Returns: 11 | list: List of tags for the video, or an empty list if tags could not be retrieved. 12 | """ 13 | try: 14 | findtags = videotags(video_url) 15 | return [tag.strip() for tag in findtags.split(",")] 16 | except Exception as e: 17 | print(f"An error occurred while retrieving tags for the video: {e}") 18 | return [] 19 | 20 | def main(): 21 | parser = argparse.ArgumentParser(description='Get YouTube video tags') 22 | parser.add_argument('url', type=str, help='YouTube video URL') 23 | 24 | args = parser.parse_args() 25 | 26 | tags = get_video_tags(args.url) 27 | print(tags) 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /src/metadata/text_clustering_and_keyword_extraction.py: -------------------------------------------------------------------------------- 1 | from sklearn.feature_extraction.text import TfidfVectorizer 2 | from sklearn.cluster import KMeans 3 | 4 | # Assume descriptions contains the text data of the video descriptions 5 | descriptions = ["Python tutorial", "How to bake a cake", "Machine learning basics", "..."] 6 | 7 | # Number of clusters 8 | num_clusters = 3 9 | 10 | # Vectorize the descriptions using TF-IDF 11 | vectorizer = TfidfVectorizer(max_features=10000) 12 | tfidf = vectorizer.fit_transform(descriptions) 13 | 14 | # Perform KMeans clustering 15 | kmeans = KMeans(n_clusters=num_clusters) 16 | kmeans.fit(tfidf) 17 | 18 | # For each cluster, print the top keywords 19 | for i in range(num_clusters): 20 | print(f"Niche #{i + 1}:") 21 | 22 | # Get the descriptions in this cluster 23 | cluster_descriptions = tfidf[kmeans.labels_ == i] 24 | 25 | # Sum the TF-IDF scores for each keyword 26 | sum_tfidf = cluster_descriptions.sum(axis=0) 27 | 28 | # Get the top 10 keywords in this cluster 29 | top_keywords_indices = sum_tfidf.argsort()[0, ::-1][:10] 30 | top_keywords = [vectorizer.get_feature_names_out()[index] for index in top_keywords_indices.flat] 31 | 32 | print(top_keywords) 33 | -------------------------------------------------------------------------------- /src/metadata/check_channel_monitization.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides a function to check whether a given YouTube channel is monetized. 3 | 4 | The function `is_monetized` sends a GET request to a specified YouTube channel URL and then 5 | parses the HTML of the page looking for the 'is_monetization_enabled' key in the script tags. 6 | It then returns `True` if the key's value is set to `true` and `False` otherwise. 7 | """ 8 | 9 | import argparse 10 | import requests 11 | from bs4 import BeautifulSoup 12 | 13 | def is_monetized(url): 14 | """ 15 | Determines if the specified YouTube channel is monetized. 16 | 17 | Args: 18 | url (str): The URL of the YouTube channel. 19 | 20 | Returns: 21 | bool: True if the channel is monetized, False otherwise. 22 | """ 23 | response = requests.get(url) 24 | soup = BeautifulSoup(response.text, 'html.parser') 25 | 26 | monetization_key = '{"key":"is_monetization_enabled","value":"true"}' 27 | 28 | scripts = soup.find_all('script') 29 | for script in scripts: 30 | # Make sure script.string is not None before checking for the monetization key 31 | if script.string and monetization_key in script.string: 32 | return True 33 | return False 34 | 35 | def main(): 36 | parser = argparse.ArgumentParser(description='Check if a YouTube channel is monetized.') 37 | parser.add_argument('url', type=str, help='The URL of the YouTube channel to check.') 38 | 39 | args = parser.parse_args() 40 | 41 | print(is_monetized(args.url)) 42 | 43 | if __name__ == "__main__": 44 | main() 45 | -------------------------------------------------------------------------------- /src/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | from sklearn.feature_extraction.text import TfidfVectorizer 2 | from sklearn.pipeline import Pipeline 3 | from sklearn.linear_model import LogisticRegression 4 | from sklearn.model_selection import train_test_split 5 | 6 | # Assume we have some dataset of videos with their metadata and labels 7 | videos = [ 8 | {"title": "Python Tutorial for Beginners", "description": "Learn Python programming", "tags": ["Python", "Programming"], "niche": "technology"}, 9 | {"title": "Easy Chocolate Cake Recipe", "description": "Delicious chocolate cake", "tags": ["Baking", "Cake"], "niche": "cooking"}, 10 | ] 11 | 12 | metadata = [video['title'] + ' ' + video['description'] + ' ' + ' '.join(video['tags']) for video in videos] 13 | labels = [video['niche'] for video in videos] 14 | 15 | # Split the data into training and testing sets 16 | metadata_train, metadata_test, labels_train, labels_test = train_test_split(metadata, labels, test_size=0.2, random_state=42) 17 | 18 | # Create a pipeline for data preprocessing and training 19 | pipeline = Pipeline([ 20 | ('tfidf', TfidfVectorizer()), 21 | ('clf', LogisticRegression()), 22 | ]) 23 | 24 | # Train the model 25 | pipeline.fit(metadata_train, labels_train) 26 | 27 | # Test the model 28 | accuracy = pipeline.score(metadata_test, labels_test) 29 | print(f"Model accuracy: {accuracy}") 30 | 31 | # Now we can use the trained model to categorize new videos 32 | video = {"title": "How to lose weight", "description": "Effective workout routines", "tags": ["Fitness", "Workout"]} 33 | metadata = video['title'] + ' ' + video['description'] + ' ' + ' '.join(video['tags']) 34 | print(f"Predicted niche: {pipeline.predict([metadata])[0]}") -------------------------------------------------------------------------------- /src/video/concatenate_videos.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from moviepy.editor import VideoFileClip, concatenate_videoclips 3 | from typing import List 4 | 5 | def concatenate_videos(videos: List[str], output: str) -> None: 6 | """ 7 | Concatenate a list of video files and save the resulting video to an output file. 8 | 9 | Args: 10 | videos (List[str]): A list of paths to the video files to be concatenated. 11 | output (str): The path to the output file where the concatenated video will be saved. 12 | 13 | Returns: 14 | None 15 | """ 16 | # list of video clips 17 | video_clips = [] 18 | 19 | for path in videos: 20 | # load video 21 | clip = VideoFileClip(path) 22 | clip = clip.subclip() 23 | 24 | # append clip to the list 25 | video_clips.append(clip) 26 | 27 | # concatenate the clips 28 | final_clip = concatenate_videoclips(video_clips) 29 | 30 | # write the final clip to file 31 | final_clip.write_videofile(output) 32 | 33 | 34 | def main(): 35 | # create a parser object 36 | parser = argparse.ArgumentParser(description="Concatenate a list of videos") 37 | 38 | # add an argument for the list of videos 39 | parser.add_argument("videos", nargs="+", type=str, help="the videos to concatenate") 40 | 41 | # add an argument for the output file name 42 | parser.add_argument("-o", "--output", type=str, default="final_video.mp4", help="the output file name") 43 | 44 | # parse the arguments 45 | args = parser.parse_args() 46 | 47 | # call the concatenate_videos function with the parsed arguments 48 | concatenate_videos(args.videos, args.output) 49 | 50 | 51 | if __name__ == "__main__": 52 | main() 53 | -------------------------------------------------------------------------------- /src/video/random_sample_clip.py: -------------------------------------------------------------------------------- 1 | import random 2 | import argparse 3 | 4 | from moviepy.video.io.VideoFileClip import VideoFileClip 5 | from moviepy.audio.io.AudioFileClip import AudioFileClip 6 | 7 | 8 | 9 | def create_clip_with_matching_audio(video_path: str, audio_path: str, output_path: str) -> None: 10 | """ 11 | Create a video clip with the same duration as the provided audio file. 12 | The video clip is extracted from the input video file and the audio is set to the provided audio file. 13 | The resulting clip is saved to the output path. 14 | 15 | Args: 16 | video_path (str): The path to the input video file. 17 | audio_path (str): The path to the input audio file. 18 | output_path (str): The path to the output file where the resulting video clip will be saved. 19 | 20 | Returns: 21 | None 22 | """ 23 | # Load video and audio files 24 | video = VideoFileClip(video_path) 25 | audio = AudioFileClip(audio_path) 26 | 27 | # Set duration of clip to match audio file 28 | duration = audio.duration 29 | 30 | # Get a random start time for the clip 31 | start_time = random.uniform(0, video.duration - duration) 32 | 33 | # Extract clip from the video 34 | clip = video.subclip(start_time, start_time + duration) 35 | 36 | # Set the audio of the clip to the audio file 37 | clip = clip.set_audio(audio) 38 | 39 | # Save the clip 40 | clip.write_videofile(output_path, audio_codec="aac") 41 | 42 | 43 | def main(): 44 | parser = argparse.ArgumentParser(description='Create a video clip with matching audio') 45 | parser.add_argument('video_path', type=str, help='path to video file') 46 | parser.add_argument('audio_path', type=str, help='path to audio file') 47 | parser.add_argument('output_path', type=str, help='path to output file') 48 | args = parser.parse_args() 49 | create_clip_with_matching_audio(args.video_path, args.audio_path, args.output_path) 50 | 51 | 52 | if __name__ == "__main__": 53 | main() 54 | -------------------------------------------------------------------------------- /src/video/create_video_from_single_image.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import pathlib 3 | import subprocess 4 | from typing import Tuple 5 | from PIL import Image 6 | 7 | def create_video_from_single_image(input_img: str, input_audio: str, output_file: str, output_extension: str = "mp4", img_size: Tuple[int, int] = (1080, 1920)) -> None: 8 | """ 9 | Creates a video using an input image and audio, with the specified output extension and image size. 10 | 11 | :param input_img: Path to the input image file. 12 | :param input_audio: Path to the input audio file. 13 | :param output_file: Name of the output video file. 14 | :param output_extension: Extension of the output video file. Default is 'mp4'. 15 | :param img_size: Tuple containing the desired image width and height. Default is (1080, 1920). 16 | """ 17 | # Resize the input image to the specified resolution 18 | image = Image.open(input_img) 19 | resized_image = image.resize(img_size) 20 | resized_image.save(input_img) 21 | 22 | # Create the video using the resized image and specified output extension 23 | output_path = pathlib.Path(output_file) 24 | output_path = output_path.with_suffix(f".{output_extension}") 25 | command = ['ffmpeg', '-loop', '1', '-i', input_img, '-i', input_audio, "-vcodec", "mpeg4", "-acodec", "aac", '-shortest', str(output_path), "-y", "-r", "2"] 26 | print(command) 27 | subprocess.run(command) 28 | 29 | 30 | def main(): 31 | parser = argparse.ArgumentParser(description="Create a video using an input image and audio.") 32 | parser.add_argument("input_img", help="Path to the input image") 33 | parser.add_argument("input_audio", help="Path to the input audio") 34 | parser.add_argument("output_file", help="Name of the output video file") 35 | parser.add_argument("-e", "--output_extension", help="Extension of the output video file", default="mp4") 36 | 37 | args = parser.parse_args() 38 | create_video_from_single_image(args.input_img, args.input_audio, args.output_file, args.output_extension) 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /src/video/cut_video.py: -------------------------------------------------------------------------------- 1 | import time 2 | import argparse 3 | from pathlib import Path 4 | 5 | import moviepy.editor as mp 6 | 7 | 8 | def split_video(input_path: Path, output_path: Path, start_time: int, end_time: int) -> None: 9 | """ 10 | Split a video file into a specified section. 11 | 12 | Args: 13 | input_path (Path): The path to the input video file. 14 | output_path (Path): The path to the output video file. 15 | start_time (int): The starting time in seconds. 16 | end_time (int): The ending time in seconds. 17 | 18 | Raises: 19 | ValueError: If the input video file does not exist or if the start or end times are invalid. 20 | 21 | """ 22 | if not input_path.exists(): 23 | raise ValueError(f"Input file {input_path} does not exist.") 24 | 25 | if ( 26 | not isinstance(start_time, int) 27 | or not isinstance(end_time, int) 28 | or start_time < 0 or end_time < 0 29 | ): 30 | raise ValueError("Start and end times must be non-negative integers.") 31 | 32 | video = mp.VideoFileClip(str(input_path)) 33 | 34 | video_clip = video.subclip(start_time, end_time) 35 | 36 | try: 37 | video_clip.write_videofile(str(output_path), fps=24) 38 | except OSError: 39 | print("Error: Unable to write output file.") 40 | time.sleep(10) 41 | output_path.unlink() 42 | video_clip.write_videofile(str(output_path), fps=24) 43 | 44 | 45 | def main(): 46 | # Parse the command-line arguments 47 | parser = argparse.ArgumentParser() 48 | parser.add_argument("input_path", type=Path, help="The path to the input video file") 49 | parser.add_argument("output_path", type=Path, help="The path to the output video file") 50 | parser.add_argument("start_time", type=int, help="The starting time in seconds") 51 | parser.add_argument("end_time", type=int, help="The ending time in seconds") 52 | args = parser.parse_args() 53 | 54 | # Call the cut_video function with the parsed arguments 55 | input_path = args.input_path 56 | output_path = args.output_path 57 | start_time = args.start_time 58 | end_time = args.end_time 59 | split_video(input_path, output_path, start_time, end_time) 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /src/test/test_audio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import pytest 4 | from pydub import AudioSegment 5 | from pyttsx3 import init 6 | import shutil 7 | from ..audio.concate_audio import ( 8 | get_sorted_audio_files, 9 | combine_audio_files_directory, 10 | combine_audio_files_with_random_pause, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def audio_files_directory(): 16 | # Create a temporary directory for the audio files 17 | temp_dir = tempfile.mkdtemp() 18 | 19 | # Create temporary audio files in the directory 20 | audio_files = ['file1.wav', 'file2.wav', 'file3.wav'] 21 | for audio_file in audio_files: 22 | file_path = os.path.join(temp_dir, audio_file) 23 | generate_audio("Sample audio", file_path) # Generate sample audio using TTS 24 | 25 | yield temp_dir 26 | 27 | # Clean up the temporary directory and files after the test 28 | for audio_file in audio_files: 29 | file_path = os.path.join(temp_dir, audio_file) 30 | os.remove(file_path) 31 | shutil.rmtree(temp_dir, ignore_errors=True) 32 | 33 | def generate_audio(text, output_file): 34 | engine = init() 35 | engine.save_to_file(text, output_file) 36 | engine.runAndWait() 37 | 38 | def test_get_sorted_audio_files(audio_files_directory): 39 | expected_files = [ 40 | os.path.join(audio_files_directory, 'file1.wav'), 41 | os.path.join(audio_files_directory, 'file2.wav'), 42 | os.path.join(audio_files_directory, 'file3.wav'), 43 | ] 44 | assert get_sorted_audio_files(audio_files_directory) == expected_files 45 | 46 | 47 | def test_combine_audio_files_directory(audio_files_directory): 48 | output_file = os.path.join(audio_files_directory, 'combined_audio.wav') 49 | combined_audio = combine_audio_files_directory(audio_files_directory, output_file) 50 | assert isinstance(combined_audio, AudioSegment) 51 | assert os.path.isfile(output_file) 52 | 53 | 54 | def test_combine_audio_files_with_random_pause(audio_files_directory): 55 | output_file = os.path.join(audio_files_directory, 'combined_audio.wav') 56 | combined_audio = combine_audio_files_with_random_pause(audio_files_directory, output_file) 57 | assert isinstance(combined_audio, AudioSegment) 58 | assert os.path.isfile(output_file) 59 | 60 | 61 | # Additional tests can be added as needed 62 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | # Local/application specific imports 5 | from src.utils import utils 6 | from src.audio import audio_utils 7 | 8 | #TODO: 9 | # remove hardcoded file related variables 10 | 11 | def validate_args(args): 12 | """ 13 | Validates the file path arguments. 14 | 15 | Args: 16 | args: The command line arguments. 17 | """ 18 | if not os.path.isfile(args.audio_link): 19 | raise ValueError(f"File not found: {args.audio_link}") 20 | if not os.path.isfile(args.vid_link): 21 | raise ValueError(f"File not found: {args.vid_link}") 22 | 23 | 24 | def main(): 25 | """ 26 | Main function to handle command line arguments and initiate the video generation. 27 | """ 28 | parser = argparse.ArgumentParser(description='Generate video with subtitles.') 29 | parser.add_argument('--audio_link', type=str, required=True, 30 | help='Path to the audio file.') 31 | parser.add_argument('--vid_link', type=str, required=True, 32 | help='Path to the video file.') 33 | parser.add_argument('--swear_word_list', type=str, required=False, default="", 34 | help='Path to the text file with a list of swear words to be filtered out.') 35 | parser.add_argument('--video_output', type=str, required=True, 36 | help='Path for the output video file.') 37 | parser.add_argument('--srtFilename', type=str, required=False, default="", 38 | help='Path for the subtitle file. If not provided, no subtitle file will be saved.') 39 | 40 | args = parser.parse_args() 41 | 42 | # Validate the arguments 43 | validate_args(args) 44 | 45 | # If no swear word list is provided, default to the predefined list 46 | if args.swear_word_list: 47 | with open(args.swear_word_list, 'r') as file: 48 | args.swear_word_list = [word.strip() for word in file.readlines()] 49 | else: 50 | args.swear_word_list = audio_utils.get_swear_word_list().keys() 51 | 52 | 53 | utils.generate_video_with_subtitles( 54 | args.audio_link, 55 | args.vid_link, 56 | args.swear_word_list, 57 | args.video_output, 58 | args.srtFilename 59 | ) 60 | 61 | 62 | 63 | if __name__ == '__main__': 64 | try: 65 | main() 66 | except Exception as e: 67 | print(f"An error occurred: {e}") -------------------------------------------------------------------------------- /src/utils/reddit_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | import praw 5 | 6 | from src.utils import text_utils 7 | 8 | # Load the .env file 9 | load_dotenv() 10 | 11 | REDDIT_CLIENT_ID = os.getenv('REDDIT_CLIENT_ID') 12 | REDDIT_CLIENT_SECRET = os.getenv('REDDIT_CLIENT_SECRET') 13 | 14 | def fetch_reddit_posts(subreddit_name:str, top_posts_limit:int=3) -> dict: 15 | # Get the data from a selected subreddit 16 | reddit_subreddit = get_subreddit(subreddit_name) 17 | 18 | # Query the top posts of the given subreddit 19 | hot_subreddit_posts = reddit_subreddit.top("all", limit=top_posts_limit) 20 | hot_subreddit_posts = [*hot_subreddit_posts] 21 | 22 | posts_dict = [{"title": text_utils.clean_up(post.title), "body": text_utils.clean_up(post.selftext)} for post in hot_subreddit_posts] 23 | 24 | return posts_dict 25 | 26 | 27 | def get_subreddit(sub:str): 28 | reddit = praw.Reddit(client_id=REDDIT_CLIENT_ID, 29 | client_secret=REDDIT_CLIENT_SECRET, 30 | user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)") 31 | 32 | # get the subreddit 33 | subreddit = reddit.subreddit(sub) 34 | return subreddit 35 | 36 | 37 | def turn_post_into_script(reddit_post,reddit_title): 38 | ending = " . Ever been in a situation like this? Leave it in the comment section. Like and subscribe if you enjoyed this video and want to see more like them. Thank you for watching my video. I hope you enjoyed it, and please have a wonderful day." 39 | opening = f"Today's story from reddit - - ... {reddit_title} ... let's get into the story ... " 40 | 41 | total_script = opening + reddit_post + ending 42 | return total_script 43 | 44 | 45 | def get_sub_comments(comment, allComments, verbose=True): 46 | allComments.append(comment) 47 | if not hasattr(comment, "replies"): 48 | replies = comment.comments() 49 | if verbose: print("fetching (" + str(len(allComments)) + " comments fetched total)") 50 | 51 | else: 52 | replies = comment.replies 53 | for child in replies: 54 | get_sub_comments(child, allComments, verbose=verbose) 55 | 56 | def get_all(r, submissionId, verbose=True): 57 | submission = r.submission(submissionId) 58 | comments = submission.comments 59 | commentsList = [] 60 | for comment in comments: 61 | get_sub_comments(comment, commentsList, verbose=verbose) 62 | return commentsList 63 | 64 | -------------------------------------------------------------------------------- /src/audio/concate_audio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import random 5 | import sys 6 | import argparse 7 | from typing import List 8 | from natsort import natsorted 9 | from pydub import AudioSegment 10 | 11 | 12 | def get_sorted_audio_files(directory: str) -> List[str]: 13 | return [os.path.join(directory, f) for f in natsorted(os.listdir(directory)) if f.endswith('.wav')] 14 | 15 | 16 | def combine_audio_files_directory(directory: str, output: str) -> AudioSegment: 17 | """ 18 | Combines all .wav audio files in a given directory into a single audio file and exports it to the specified output file. 19 | 20 | :param directory: Path to the directory containing the audio files to be combined. 21 | :param output: Path to the output file where the combined audio will be saved. 22 | :return: The combined audio as a PyDub AudioSegment object. 23 | """ 24 | audio_files = get_sorted_audio_files(directory) 25 | combined_audio = AudioSegment.empty() 26 | for audio_file in audio_files: 27 | audio = AudioSegment.from_file(audio_file) 28 | combined_audio += audio 29 | combined_audio.export(output, format='wav') 30 | return combined_audio 31 | 32 | 33 | def combine_audio_files_with_random_pause(directory:str, output:str) -> AudioSegment: 34 | """ 35 | Combines all .wav audio files in a given directory into a single audio file with a random pause (300ms to 500ms) between 36 | each file and exports it to the specified output file. 37 | 38 | :param directory: Path to the directory containing the audio files to be combined. 39 | :param output: Path to the output file where the combined audio will be saved. 40 | :return: The combined audio as a PyDub AudioSegment object. 41 | """ 42 | combined_audio = AudioSegment.empty() 43 | audio_files = get_sorted_audio_files(directory) 44 | combined_audio = AudioSegment.from_file(audio_files[0]) 45 | for audio_file in audio_files[1:]: 46 | pause_duration = random.randint(300, 500) 47 | combined_audio += AudioSegment.silent(duration=pause_duration) 48 | audio = AudioSegment.from_file(audio_file) 49 | combined_audio += audio 50 | combined_audio.export(output, format='wav') 51 | return combined_audio 52 | 53 | 54 | def main(args: List[str]) -> None: 55 | parser = argparse.ArgumentParser(description='Combine multiple audio files into one') 56 | parser.add_argument('directory', metavar='DIRECTORY', help='directory containing the audio files to combine') 57 | parser.add_argument('-o', '--output', default='combined_audio.wav', help='output filename (default: combined_audio.wav)') 58 | parser.add_argument('--pause', action='store_true', help='add random pause between files') 59 | args = parser.parse_args(args) 60 | 61 | if args.pause: 62 | combine_audio_files_with_random_pause(args.directory, args.output) 63 | else: 64 | combine_audio_files_directory(args.directory, args.output) 65 | 66 | 67 | 68 | if __name__ == '__main__': 69 | main(sys.argv[1:]) 70 | -------------------------------------------------------------------------------- /src/metadata/youtube_search.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.request 3 | import re 4 | import requests 5 | from dotenv import load_dotenv 6 | from typing import Union, List 7 | 8 | def get_unique_video_ids(search_query: str) -> List[str]: 9 | """ 10 | Get the unique video IDs from YouTube search results. 11 | 12 | Args: 13 | search_query (str): The search query to use on YouTube. 14 | 15 | Returns: 16 | List[str]: A list of unique video IDs from the search results. 17 | """ 18 | # Replace white spaces with a "+" symbol 19 | search_query = search_query.replace(" ", "+") 20 | 21 | # Create a URL to search on YouTube using the search query 22 | url = f"https://www.youtub.com/results?search_query={search_query}&sp=CAMSBAgEEAE%253D" 23 | 24 | # Open the URL and read the HTML response 25 | with urllib.request.urlopen(url) as html: 26 | html_content = html.read().decode() 27 | 28 | # Find all video IDs in the HTML content using a regular expression 29 | video_ids = re.findall(r"watch\?v=(\S{11})", html_content) 30 | 31 | # Convert the list of video IDs to a set to remove duplicates and then convert it back to a list 32 | unique_video_ids = list(set(video_ids)) 33 | return unique_video_ids 34 | 35 | 36 | def get_video_views(video_id: str, api_key: str) -> Union[str, None]: 37 | """ 38 | Get the view count for a specific YouTube video. 39 | 40 | Args: 41 | video_id (str): The ID of the YouTube video. 42 | api_key (str): The Google API key. 43 | 44 | Returns: 45 | Union[str, None]: The view count for the video as a string, or None if an error occurred. 46 | """ 47 | # Construct the API URL 48 | url = f'https://www.googleapis.com/youtube/v3/videos?part=statistics&id={video_id}&key={api_key}' 49 | 50 | # Make a GET request to the API 51 | response = requests.get(url) 52 | 53 | # Check if the request was successful 54 | if response.status_code == 200: 55 | # Load the JSON response into a Python dictionary 56 | data = response.json() 57 | 58 | # Get the view count from the statistics object 59 | view_count = data['items'][0]['statistics']['viewCount'] 60 | 61 | # Return the view count 62 | return view_count 63 | else: 64 | # If the request was not successful, raise an exception 65 | raise Exception(f'Error retrieving video data: {response.text}') 66 | 67 | 68 | def main(): 69 | # Load environment variables from .env file 70 | load_dotenv() 71 | 72 | # Get the API key from the environment variable GOOGLE_API_KEY 73 | API_KEY = os.getenv('GOOGLE_API_KEY') 74 | 75 | # Check if the API key was successfully loaded 76 | if not API_KEY: 77 | raise Exception('Missing API key. Please set the environment variable GOOGLE_API_KEY in the .env file.') 78 | 79 | # Example usage 80 | search_query = "Mozart" 81 | video_ids = get_unique_video_ids(search_query) 82 | for video_id in video_ids: 83 | view_count = get_video_views(video_id, API_KEY) 84 | print(f'The video with ID {video_id} has {view_count} views.') 85 | 86 | # Run the main function 87 | if __name__ == "__main__": 88 | main() 89 | -------------------------------------------------------------------------------- /src/metadata/keyword_analysis.py: -------------------------------------------------------------------------------- 1 | import os 2 | import matplotlib.pyplot as plt 3 | from pytrends.request import TrendReq 4 | import pandas as pd 5 | import datetime 6 | from typing import List, Optional 7 | 8 | def validate_inputs(keywords: List[str], timeframe: str) -> None: 9 | """ 10 | Validate the inputs for the get_trends function. 11 | 12 | Parameters 13 | ---------- 14 | keywords : list of str 15 | List of keywords to get trends data for. 16 | timeframe : str 17 | Timeframe for the trends data. 18 | """ 19 | if not isinstance(keywords, list): 20 | raise ValueError("Keywords should be a list of strings.") 21 | 22 | valid_timeframes = ['now 1-H', 'now 4-H', 'now 1-d', 'now 7-d', 'today 1-m', 'today 3-m', 'today 12-m', 'today 5-y', 'all'] 23 | if timeframe not in valid_timeframes: 24 | raise ValueError("Invalid timeframe. Check the Google Trends API for valid timeframes.") 25 | 26 | 27 | def get_trends(keywords: List[str], timeframe: str = 'today 5-y') -> pd.DataFrame: 28 | """ 29 | Get Google Trends data for a list of keywords. 30 | 31 | Args: 32 | keywords (List[str]): List of keywords to get trends data for. 33 | timeframe (str, optional): Timeframe for the trends data, defaults to 'today 5-y'. 34 | 35 | Returns: 36 | pd.DataFrame: DataFrame with the trends data. 37 | """ 38 | pytrends = TrendReq(hl='en-US', tz=360) 39 | 40 | # Build the payload 41 | pytrends.build_payload(keywords, timeframe=timeframe) 42 | 43 | # Get Google Trends data 44 | trends_data = pytrends.interest_over_time() 45 | 46 | return trends_data 47 | 48 | 49 | def plot_trends(trend_data: pd.DataFrame, save: bool = False, filename: Optional[str] = None) -> None: 50 | """ 51 | Plot Google Trends data. 52 | 53 | Parameters 54 | ---------- 55 | trend_data : pandas.DataFrame 56 | DataFrame with the trends data. 57 | save : bool, optional 58 | Whether to save the plot as a PNG image, defaults to False. 59 | filename : str, optional 60 | Filename for the PNG image, defaults to None. 61 | """ 62 | plt.figure(figsize=(14, 8)) 63 | for keyword in trend_data.columns[:-1]: # Exclude the 'isPartial' column 64 | plt.plot(trend_data.index, trend_data[keyword], label=keyword) 65 | plt.xlabel('Date') 66 | plt.ylabel('Trends Index') 67 | plt.title('Google Search Trends over time') 68 | plt.grid(True) 69 | plt.legend() 70 | plt.show() 71 | 72 | if save: 73 | if filename is None: 74 | # Create filename with current datetime if not specified 75 | filename = f"graph_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.png" 76 | plt.savefig(filename, format='png') # Save the plot as a PNG image 77 | 78 | def main() -> None: 79 | """ 80 | Main function to get and plot Google Trends data. 81 | """ 82 | # List of keywords to get trends data for 83 | keywords = ['Blockchain', 'pizza', 'Australian Cattle Dog','AI',"Chinese Food"] 84 | 85 | # Get the trends data 86 | trend_data = get_trends(keywords) 87 | 88 | # Plot the trends data 89 | plot_trends(trend_data) 90 | 91 | if __name__ == "__main__": 92 | main() 93 | -------------------------------------------------------------------------------- /gui_upload_video.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import filedialog 3 | from argparse import Namespace 4 | 5 | from src.utils.upload_video import get_authenticated_service, initialize_upload, validate_shorts 6 | from googleapiclient.errors import HttpError 7 | 8 | def browse_files(entry): 9 | filename = filedialog.askopenfilename(initialdir = "/", title = "Select a File", filetypes = (("Text files", "*.mp4*"), ("all files", "*.*"))) 10 | entry.delete(0, tk.END) 11 | entry.insert(tk.END, filename) 12 | 13 | def upload(): 14 | options = Namespace( 15 | file=file_entry.get(), 16 | title=title_entry.get(), 17 | description=description_entry.get(), 18 | category=category_entry.get(), 19 | keywords=keywords_entry.get(), 20 | privacyStatus=privacy_status_var.get(), 21 | thumbnail=thumbnail_entry.get(), 22 | madeForKids=bool(made_for_kids_var.get()), 23 | youtubeShort=bool(youtube_short_var.get()) 24 | ) 25 | 26 | if options.youtubeShort: 27 | validate_shorts(options) 28 | 29 | youtube = get_authenticated_service() 30 | try: 31 | initialize_upload(youtube, options) 32 | except HttpError as e: 33 | print(f'An HTTP error {e.resp.status} occurred:\n{e.content}') 34 | 35 | root = tk.Tk() 36 | 37 | file_label = tk.Label(root, text="Video file") 38 | file_label.pack() 39 | file_entry = tk.Entry(root) 40 | file_entry.pack() 41 | browse_button = tk.Button(root, text="Browse", command=lambda: browse_files(file_entry)) 42 | browse_button.pack() 43 | 44 | title_label = tk.Label(root, text="Title") 45 | title_label.pack() 46 | title_entry = tk.Entry(root) 47 | title_entry.insert(tk.END, 'Test Title') 48 | title_entry.pack() 49 | 50 | description_label = tk.Label(root, text="Description") 51 | description_label.pack() 52 | description_entry = tk.Entry(root) 53 | description_entry.insert(tk.END, 'Test Description') 54 | description_entry.pack() 55 | 56 | category_label = tk.Label(root, text="Category") 57 | category_label.pack() 58 | category_entry = tk.Entry(root) 59 | category_entry.insert(tk.END, '27') 60 | category_entry.pack() 61 | 62 | keywords_label = tk.Label(root, text="Keywords") 63 | keywords_label.pack() 64 | keywords_entry = tk.Entry(root) 65 | keywords_entry.insert(tk.END, '') 66 | keywords_entry.pack() 67 | 68 | privacy_status_var = tk.StringVar(root) 69 | privacy_status_var.set("private") 70 | privacy_status_option = tk.OptionMenu(root, privacy_status_var, "public", "private", "unlisted") 71 | privacy_status_option.pack() 72 | 73 | thumbnail_label = tk.Label(root, text="Thumbnail") 74 | thumbnail_label.pack() 75 | thumbnail_entry = tk.Entry(root) 76 | thumbnail_entry.insert(tk.END, '') 77 | thumbnail_entry.pack() 78 | thumbnail_browse_button = tk.Button(root, text="Browse", command=lambda: browse_files(thumbnail_entry)) 79 | thumbnail_browse_button.pack() 80 | 81 | made_for_kids_var = tk.BooleanVar(root) 82 | made_for_kids_check = tk.Checkbutton(root, text='Made for Kids', variable=made_for_kids_var) 83 | made_for_kids_check.pack() 84 | 85 | youtube_short_var = tk.BooleanVar(root) 86 | youtube_short_check = tk.Checkbutton(root, text='YouTube Shorts', variable=youtube_short_var) 87 | youtube_short_check.pack() 88 | 89 | upload_button = tk.Button(root, text="Upload", command=upload) 90 | upload_button.pack() 91 | 92 | root.mainloop() 93 | -------------------------------------------------------------------------------- /src/audio/audio_utils.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Dict, Union 2 | import re 3 | import os 4 | 5 | import csv 6 | from pydub import AudioSegment 7 | 8 | from src.audio import concate_audio 9 | from src.utils import (generate_subtitles, text_utils) 10 | 11 | 12 | SWEAR_WORD_LIST_FILE_LOCATION = os.getenv('SWEAR_WORD_LIST_FILE_LOCATION_FILE_LOCATION') 13 | 14 | def silence_segments(input_file, output_file, segments): 15 | '''silences all selected segments''' 16 | # Load audio file 17 | audio = AudioSegment.from_file(input_file) 18 | 19 | # Loop over the list of segments 20 | for segment in segments: 21 | # Calculate the start and end times in milliseconds 22 | start_ms = segment['start'] * 1000 23 | end_ms = segment['end'] * 1000 24 | 25 | # Create a silent audio segment with the same duration as the specified segment 26 | duration = end_ms - start_ms 27 | silent_segment = AudioSegment.silent(duration=duration) 28 | 29 | # Replace the segment with the silent audio 30 | audio = audio[:start_ms] + silent_segment + audio[end_ms:] 31 | 32 | # Export the modified audio to a file 33 | audio.export(output_file, format="wav") 34 | 35 | def make_family_friendly(input_data:str,swear_word_list:List[str],output_data:str="output0.wav"): 36 | x = generate_subtitles.transcribe_and_align(input_data) 37 | x_word_segments = x['word_segments'] 38 | 39 | swear_word_segements = text_utils.filter_text_by_list(x_word_segments,swear_word_list) 40 | 41 | silence_segments(input_data, output_data, swear_word_segements) 42 | 43 | def mask_swear_segments(word_list: List[str], x_word_segments: List[Dict[str, Union[str, float]]]) -> List[Dict[str, Union[str, float]]]: 44 | x_word_segments_copy = [] 45 | for i in x_word_segments: 46 | segment_copy = i.copy() 47 | segment_copy['text'] = mask_specific_words(word_list, i['text']) 48 | x_word_segments_copy.append(segment_copy) 49 | return x_word_segments_copy 50 | 51 | def remove_swears(audio_script:str) ->str: 52 | links_dict = get_swear_word_list() 53 | 54 | for word, replacement in links_dict.items(): 55 | audio_script = audio_script.replace(word, replacement) 56 | 57 | return audio_script 58 | 59 | def get_swear_word_list(): 60 | with open(SWEAR_WORD_LIST_FILE_LOCATION, 'r') as f: 61 | reader = csv.reader(f) 62 | # create a dictionary with the first column as the keys and the second column as the values 63 | links_dict = {rows[0]: rows[1] for rows in reader} 64 | return links_dict 65 | 66 | def mask_word(match): 67 | word = match.group(0) 68 | return word[0] + "*" * (len(word) - 2) + word[-1] 69 | 70 | def mask_specific_words(words_to_mask: List[str], string_to_mask: str) -> str: 71 | """ 72 | Mask specific words in a given string by replacing them with asterisks, while preserving the first and last characters. 73 | 74 | Args: 75 | words_to_mask (List[str]): List of words to mask. 76 | string_to_mask (str): String to be masked. 77 | 78 | Returns: 79 | str: Masked string. 80 | """ 81 | # Create a set of unique words to mask for faster lookup 82 | words_set = set(words_to_mask) 83 | 84 | # Compile the regex pattern to match any of the words to mask 85 | pattern = re.compile(r"\b(?:{})\b".format("|".join(re.escape(word) for word in words_set)), flags=re.IGNORECASE) 86 | 87 | # Replace the matched words with asterisks, preserving the first and last characters 88 | 89 | # Perform the replacement using the compiled pattern and mask_word function 90 | masked_string = pattern.sub(mask_word, string_to_mask) 91 | 92 | return masked_string -------------------------------------------------------------------------------- /examples/reddit_to_video.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module fetches posts from a subreddit, converts one of the posts into audio, 3 | then generates a video with subtitles from the audio and a sample video. 4 | """ 5 | 6 | import os 7 | import sys 8 | import logging 9 | from dotenv import load_dotenv 10 | from pathlib import Path 11 | 12 | # Third party imports 13 | from elevenlabs import set_api_key, generate, save 14 | 15 | # Local/application specific imports 16 | from src.utils import reddit_api, utils 17 | from src.audio import audio_utils 18 | 19 | def check_environment_variables(): 20 | """ 21 | Checks if necessary environment variables are set. 22 | 23 | This function gets the 'ELEVENLABS_API_KEY' and 'STRING_AUDIO_FILE_LOCATION' 24 | from the environment variables and checks if they are set. 25 | 26 | Returns: 27 | ELEVENLABS_API_KEY (str): The API key for elevenlabs. 28 | STRING_AUDIO_FILE_LOCATION (str): The location to store the audio file. 29 | 30 | Raises: 31 | SystemExit: If the environment variables are not set. 32 | """ 33 | ELEVENLABS_API_KEY = os.getenv('ELEVENLABS_API_KEY') 34 | if not ELEVENLABS_API_KEY: 35 | logging.error("The ELEVENLABS_API_KEY environment variable is not set.") 36 | sys.exit(1) 37 | 38 | STRING_AUDIO_FILE_LOCATION = os.getenv("STRING_AUDIO_FILE_LOCATION") 39 | if not STRING_AUDIO_FILE_LOCATION: 40 | logging.error("The STRING_AUDIO_FILE_LOCATION environment variable is not set.") 41 | sys.exit(1) 42 | 43 | return ELEVENLABS_API_KEY, STRING_AUDIO_FILE_LOCATION 44 | 45 | # This function fetches posts from the specified subreddit 46 | def fetch_reddit_posts(subreddit): 47 | return reddit_api.fetch_reddit_posts(subreddit) 48 | 49 | # This function creates a directory in the specified location to store the audio file 50 | def create_audio_directory(audio_file_location): 51 | audio_directory = utils.create_next_dir(audio_file_location) 52 | current_directory = os.getcwd() 53 | directory_path = os.path.join(current_directory, Path(audio_directory)) 54 | return directory_path 55 | 56 | # This function generates an audio file from the specified script using the Eleven Labs API 57 | def generate_audio(script, voice, model): 58 | return generate(text=script, voice=voice, model=model) 59 | 60 | def main(api_key, audio_file_location): 61 | subreddit = 'dndstories' 62 | 63 | # Fetch posts from the subreddit 64 | posts_dict = fetch_reddit_posts(subreddit) 65 | first_story = posts_dict[-1] 66 | 67 | # Convert the first post into a script 68 | script_first_story = reddit_api.turn_post_into_script( 69 | first_story['body'], first_story['title']) 70 | 71 | # Create a directory to store the audio file 72 | directory_path = create_audio_directory(audio_file_location) 73 | complete_audio_path = os.path.join(directory_path, "story_part_0.wav") 74 | 75 | # Fetch the list of swear words to filter 76 | swear_word_list = [*audio_utils.get_swear_word_list().keys()] 77 | 78 | # Generate the audio from the script 79 | audio = generate_audio(script_first_story, voice="Bella", model="eleven_monolingual_v1") 80 | save(audio, complete_audio_path) 81 | 82 | input_video_file = r'sample_video.mp4' 83 | output_video_file = r"sample_0.mp4" 84 | 85 | # Generate the final video with subtitles, filtering out any swear words 86 | utils.generate_video_with_subtitles( 87 | complete_audio_path, input_video_file, swear_word_list, output_video_file) 88 | 89 | if __name__ == '__main__': 90 | logging.basicConfig(level=logging.INFO) 91 | load_dotenv() 92 | 93 | # Check environment variables and proceed if all necessary variables are set 94 | ELEVENLABS_API_KEY, STRING_AUDIO_FILE_LOCATION = check_environment_variables() 95 | set_api_key(ELEVENLABS_API_KEY) 96 | main(ELEVENLABS_API_KEY, STRING_AUDIO_FILE_LOCATION) 97 | -------------------------------------------------------------------------------- /src/utils/text_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Dict, Union 3 | 4 | 5 | def replace_caps_with_hyphens(sentence): 6 | pattern = r'\b([A-Z]+)\b' 7 | replacement = lambda match: '-'.join(list(match.group(1))) 8 | return re.sub(pattern, replacement, sentence) 9 | 10 | 11 | def remove_parenthesis(text:str): 12 | # define the pattern to match 13 | pattern = r'\(([^\s()]+)\)' 14 | # remove the tokens from the string using regular expressions 15 | # remove any text enclosed in parentheses if it contains only one word 16 | text_without_single_word_parentheses = re.sub( 17 | pattern, lambda m: m.group(1) if ' ' in m.group(1) else '', text 18 | ) 19 | # return text_without_tokens 20 | return text_without_single_word_parentheses 21 | 22 | 23 | def replace_hyphens_with_single_space(text): 24 | 25 | return re.sub(r'-\B|\B-', ' ', text) 26 | 27 | def add_spaces_around_hyphen(words): 28 | 29 | return re.sub(r'([a-zA-Z])-([a-zA-Z])', r'\1 - \2', words) 30 | 31 | def add_spaces_around_hyphens(input_str): 32 | # Replace all hyphens with a space followed by a hyphen followed by another space 33 | # Example: 'A-I-T-A' -> 'A - I - T - A' 34 | 35 | output_str = re.sub(r'-', ' - ', input_str) 36 | 37 | return output_str 38 | 39 | def clean_up(text:str) -> str: 40 | 41 | text = " ".join(text.split()) 42 | 43 | text = remove_parenthesis(text) 44 | 45 | text = text.replace("\\"," slash ") 46 | 47 | if "r/" in text: 48 | text = text.replace("r/", " R slash ") 49 | 50 | if "AITA" in text: 51 | text = text.replace("AITA", " am i the asshole ") 52 | 53 | 54 | text = replace_hyphens_with_single_space(text) 55 | text = replace_caps_with_hyphens(text) 56 | text = add_spaces_around_hyphen(text) 57 | 58 | 59 | return text 60 | 61 | 62 | 63 | def join_sentences(sentences: List[str]) -> List[str]: 64 | '''splits body of text such that it never surpases maximum token 250''' 65 | result = [] 66 | current_sentence = "" 67 | current_word_count = 0 68 | 69 | for sentence in sentences: 70 | # split sentence into words and add to current word count 71 | words = sentence.split() 72 | current_word_count += len(words) 73 | 74 | # if adding the current sentence would result in too many words, add the current sentence to the result 75 | if current_word_count > 249: 76 | result.append(current_sentence) 77 | current_sentence = "" 78 | current_word_count = 0 79 | 80 | # add current sentence and a space to the result 81 | if len(current_sentence) > 0: 82 | current_sentence += " " 83 | 84 | # add current sentence to the result 85 | current_sentence += sentence 86 | 87 | # add final sentence to the result 88 | result.append(current_sentence) 89 | 90 | return result 91 | 92 | 93 | 94 | def turn_post_into_script(reddit_post,reddit_title): 95 | ending = " . Ever been in a situation like this? Leave it in the comment section. Like and subscribe if you enjoyed this video and want to see more like them. Thank you for watching my video. I hope you enjoyed it, and please have a wonderful day." 96 | opening = f"Today's story from reddit - - ... {reddit_title} ... let's get into the story ... " 97 | 98 | total_script = opening + reddit_post + ending 99 | return total_script 100 | 101 | 102 | 103 | def filter_text_by_list(text_list: List[Dict[str, Union[str, float]]], word_list: List[str]) -> List[Dict[str, Union[str, float]]]: 104 | '''returns segments of swear words''' 105 | filtered_list = [] 106 | for item in text_list: 107 | # Remove all non-alphanumeric characters from the item's text 108 | cleaned_text = re.sub(r'[^a-zA-Z\d\s]', '', item['text']) 109 | if cleaned_text.lower() in word_list: 110 | filtered_list.append(item) 111 | return filtered_list 112 | -------------------------------------------------------------------------------- /src/metadata/video_engagement_metrics.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | from googleapiclient.discovery import build 4 | from dotenv import load_dotenv 5 | 6 | def get_video_engagement_metrics(video_id: str, api_key: str) -> dict: 7 | """ 8 | Fetch engagement metrics for a specific YouTube video. 9 | 10 | Args: 11 | video_id (str): The ID of the YouTube video. 12 | api_key (str): The Google API key. 13 | 14 | Returns: 15 | dict: A dictionary containing engagement metrics and video metadata. 16 | """ 17 | # Build the YouTube API client 18 | youtube = build('youtube', 'v3', developerKey=api_key) 19 | 20 | # Make the API request to get video statistics and snippet (for metadata) 21 | response = youtube.videos().list( 22 | part='statistics,snippet', 23 | id=video_id 24 | ).execute() 25 | 26 | # Extract engagement metrics and metadata from the response 27 | engagement_metrics = response['items'][0]['statistics'] 28 | metadata = response['items'][0]['snippet'] 29 | 30 | # Add metadata to the engagement metrics dictionary 31 | engagement_metrics.update(metadata) 32 | 33 | return engagement_metrics 34 | 35 | 36 | def get_video_comments(video_id: str, api_key: str, max_results: int = 20, include_replies: bool = True) -> list: 37 | """ 38 | Fetch comments for a specific YouTube video. 39 | 40 | Args: 41 | video_id (str): The ID of the YouTube video. 42 | api_key (str): The Google API key. 43 | max_results (int, optional): Maximum number of comments to return. Defaults to 20. 44 | include_replies (bool, optional): If True, includes replies to comments. Defaults to True. 45 | 46 | Returns: 47 | list: A list containing comments, booleans indicating if it is a reply, the comment's publish datetime, 48 | like count, and author channel Id. 49 | """ 50 | # Build the YouTube API client 51 | youtube = build('youtube', 'v3', developerKey=api_key) 52 | 53 | # Fetch comments for the video 54 | response = youtube.commentThreads().list( 55 | part='snippet,replies', 56 | videoId=video_id, 57 | textFormat='plainText', 58 | maxResults=max_results 59 | ).execute() 60 | 61 | # Extract comments from the response 62 | comments = [] 63 | for item in response['items']: 64 | comment = item['snippet']['topLevelComment']['snippet'] 65 | comments.append( 66 | { 67 | 'text': comment['textDisplay'], 68 | 'is_reply': False, 69 | 'like_count': comment['likeCount'], 70 | 'author_channel_id': comment['authorChannelId']['value'], 71 | 'publish_time': comment['publishedAt'] 72 | } 73 | ) 74 | 75 | # Extract replies if any 76 | if include_replies and 'replies' in item: 77 | for reply in item['replies']['comments']: 78 | reply_comment = reply['snippet'] 79 | comments.append( 80 | { 81 | 'text': reply_comment['textDisplay'], 82 | 'is_reply': True, 83 | 'like_count': reply_comment['likeCount'], 84 | 'author_channel_id': reply_comment['authorChannelId']['value'], 85 | 'publish_time': reply_comment['publishedAt'] 86 | } 87 | ) 88 | 89 | return comments 90 | 91 | 92 | def main(): 93 | # Load environment variables 94 | load_dotenv() 95 | 96 | # Get the API key from the environment variables 97 | api_key = os.getenv("GOOGLE_API_KEY") 98 | 99 | # Setup argument parser 100 | parser = argparse.ArgumentParser(description='Fetch engagement metrics or comments for a YouTube video.') 101 | group = parser.add_mutually_exclusive_group(required=True) 102 | group.add_argument('--metrics', action='store_true', help='Get engagement metrics') 103 | group.add_argument('--comments', type=int, nargs='?', const=20, help='Get comments') 104 | parser.add_argument('--no-replies', action='store_true', help='Exclude replies to comments') 105 | parser.add_argument('--order', type=str, choices=['relevance', 'time', 'rating', 'videoLikes', 'videoRelevance'], default='relevance', help='Order of comments') 106 | parser.add_argument('video_id', type=str, help='The ID of the YouTube video') 107 | 108 | # Parse command-line arguments 109 | args = parser.parse_args() 110 | 111 | if args.metrics: 112 | # Get and print engagement metrics 113 | engagement_metrics = get_video_engagement_metrics(args.video_id, api_key) 114 | print(engagement_metrics) 115 | elif args.comments is not None: 116 | # Get and print comments 117 | comments = get_video_comments(args.video_id, api_key, args.comments, not args.no_replies, args.order) 118 | for comment, is_reply, datetime in comments: 119 | print(f'{"Reply" if is_reply else "Comment"} ({datetime}): {comment}') 120 | 121 | if __name__ == "__main__": 122 | main() 123 | -------------------------------------------------------------------------------- /src/utils/upload_video.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | import argparse 3 | import os 4 | 5 | from googleapiclient.http import MediaFileUpload 6 | from googleapiclient.errors import HttpError 7 | from google_auth_oauthlib.flow import InstalledAppFlow 8 | from googleapiclient.discovery import build 9 | 10 | from moviepy.editor import VideoFileClip 11 | 12 | 13 | CLIENT_SECRETS_FILE = "client_secret.json" 14 | SCOPES = ['https://www.googleapis.com/auth/youtube.upload'] 15 | API_SERVICE_NAME = 'youtube' 16 | API_VERSION = 'v3' 17 | 18 | def get_authenticated_service() -> build: 19 | """ 20 | Authenticate the user using OAuth2. 21 | 22 | Returns: 23 | The authenticated service that can be used to interact with the YouTube API. 24 | """ 25 | flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_FILE, SCOPES) 26 | credentials = flow.run_console() 27 | return build(API_SERVICE_NAME, API_VERSION, credentials=credentials) 28 | 29 | 30 | def validate_shorts(options: Namespace) -> None: 31 | """ 32 | Validate that the video file meets the requirements for YouTube Shorts. 33 | 34 | Parameters: 35 | options (Namespace): Command line arguments. 36 | """ 37 | # Check the video format and duration 38 | video = VideoFileClip(options.file) 39 | width, height = video.size 40 | duration = video.duration 41 | 42 | # Check if video is vertical (aspect ratio of 9:16) 43 | if width / height != 9 / 16: 44 | raise ValueError("Video is not in the correct aspect ratio for YouTube Shorts. It must be a vertical video (aspect ratio 9:16).") 45 | 46 | # Check if video is no longer than 60 seconds 47 | if duration > 60: 48 | raise ValueError("Video is too long for YouTube Shorts. It must be 60 seconds or less.") 49 | 50 | 51 | def initialize_upload(youtube: build, options: Namespace) -> None: 52 | """ 53 | Initialize the video upload to YouTube. 54 | 55 | Parameters: 56 | youtube (build): The authenticated YouTube API service. 57 | options (Namespace): Command line arguments. 58 | """ 59 | tags = None 60 | if options.keywords: 61 | tags = options.keywords.split(',') 62 | 63 | # Check if the video is a YouTube short and append "#Shorts" to the title 64 | title = options.title 65 | if options.youtubeShort: 66 | title += " #Shorts" 67 | 68 | body=dict( 69 | snippet=dict( 70 | title=title, 71 | description=options.description, 72 | tags=tags, 73 | categoryId=options.category 74 | ), 75 | status=dict( 76 | privacyStatus=options.privacyStatus, 77 | madeForKids=options.madeForKids 78 | ) 79 | ) 80 | 81 | insert_request = youtube.videos().insert( 82 | part=",".join(body.keys()), 83 | body=body, 84 | media_body=MediaFileUpload(options.file, chunksize=-1, resumable=True) 85 | ) 86 | 87 | resumable_upload(youtube, insert_request, options) 88 | 89 | def resumable_upload(youtube: build, insert_request: object, options: Namespace) -> None: 90 | """ 91 | Upload the video file to YouTube and track its progress. 92 | 93 | Parameters: 94 | youtube (build): The authenticated YouTube API service. 95 | insert_request (object): The insert request object. 96 | options (Namespace): Command line arguments. 97 | """ 98 | response = None 99 | while response is None: 100 | status, response = insert_request.next_chunk() 101 | if 'id' in response: 102 | print(f"Video id {response['id']} was successfully uploaded.") 103 | set_thumbnail(youtube, options, response['id']) 104 | 105 | def set_thumbnail(youtube: build, options: Namespace, video_id: str) -> None: 106 | """ 107 | Set the thumbnail of the uploaded video. 108 | 109 | Parameters: 110 | youtube (build): The authenticated YouTube API service. 111 | options (Namespace): Command line arguments. 112 | video_id (str): The ID of the uploaded video. 113 | """ 114 | youtube.thumbnails().set( 115 | videoId=video_id, 116 | media_body=MediaFileUpload(options.thumbnail) 117 | ).execute() 118 | 119 | def main(): 120 | """ 121 | Parse command line arguments and upload a video to YouTube. 122 | """ 123 | parser = argparse.ArgumentParser() 124 | parser.add_argument('--file', required=True, help='Video file to upload') 125 | parser.add_argument('--title', help='Video title', default='Test Title') 126 | parser.add_argument('--description', help='Video description', default='Test Description') 127 | parser.add_argument('--category', default='27', help='Numeric video category. See https://developers.google.com/youtube/v3/docs/videoCategories/list') 128 | parser.add_argument('--keywords', help='Video keywords, comma separated', default='') 129 | parser.add_argument('--privacyStatus', choices=['public', 'private', 'unlisted'], default='private', help='Video privacy status.') 130 | parser.add_argument('--thumbnail', help='Thumbnail image file', default='') 131 | parser.add_argument('--madeForKids', type=bool, default=False, help='Made for kids field.') 132 | parser.add_argument('--youtubeShort', type=bool, default=False, help='Is this a YouTube short, if so it must have a aspect ratio of 9:16?') # Added 'youtubeShort' argument 133 | args = parser.parse_args() 134 | 135 | if args.youtubeShort: 136 | validate_shorts(args) 137 | 138 | youtube = get_authenticated_service() 139 | try: 140 | initialize_upload(youtube, args) 141 | except HttpError as e: 142 | print(f'An HTTP error {e.resp.status} occurred:\n{e.content}') 143 | 144 | if __name__ == '__main__': 145 | main() -------------------------------------------------------------------------------- /src/utils/play_ht_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import os 4 | import time 5 | import urllib.request 6 | from dotenv import load_dotenv 7 | import math 8 | 9 | # Load the .env file 10 | load_dotenv() 11 | 12 | # Get the value of the PLAYHT_API_KEY variable 13 | PLAYHT_API_KEY = os.getenv('PLAYHT_API_KEY') 14 | PLAYHT_API_USER_ID = os.getenv('PLAYHT_API_USER_ID') 15 | 16 | 17 | def generate_ultra_track(body, voice="Larry", speed="0.85"): 18 | """ 19 | Generate an audio track using Play.ht API with the given text, voice, and speed. 20 | 21 | Args: 22 | body (str): The text content to be converted to audio. 23 | voice (str, optional): The voice to be used for the audio. Defaults to "Larry". 24 | speed (str, optional): The speed of the audio playback. Defaults to "0.85". 25 | 26 | Returns: 27 | str: The transcription ID of the generated audio track. 28 | """ 29 | url = "https://play.ht/api/v1/convert" 30 | 31 | payload = json.dumps({ 32 | "voice": voice, 33 | "content": [ 34 | body, 35 | ], 36 | "speed": speed, 37 | "preset": "balanced" 38 | }) 39 | headers = { 40 | 'Authorization': PLAYHT_API_KEY, 41 | 'X-User-ID': PLAYHT_API_USER_ID, 42 | 'Content-Type': 'application/json' 43 | } 44 | 45 | response = requests.request("POST", url, headers=headers, data=payload) 46 | print(response.text) 47 | return json.loads(response.text)['transcriptionId'] 48 | 49 | 50 | def download_file(file_url, file_name, directory): 51 | """ 52 | Download a file from the specified URL and save it to the given directory with the given file name. 53 | 54 | Args: 55 | file_url (str): The URL of the file to download. 56 | file_name (str): The name to save the downloaded file as. 57 | directory (str): The directory to save the file in. 58 | 59 | Returns: 60 | None 61 | """ 62 | if not os.path.exists(directory): 63 | os.makedirs(directory) 64 | 65 | file_path = os.path.join(directory, file_name) 66 | headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} 67 | r = requests.get(file_url,headers=headers) 68 | 69 | 70 | url = 'https://example.com/myfile.txt' 71 | local_filename, headers = urllib.request.urlretrieve(file_url, file_path) 72 | 73 | 74 | def ultra_play_ht_get_id(transaction_id: str): 75 | """ 76 | Get the audio URL for the given transcription ID using Play.ht API. 77 | 78 | Args: 79 | transaction_id (str): The transcription ID to get the audio URL for. 80 | 81 | Returns: 82 | str: The audio URL of the generated audio track. 83 | """ 84 | url = f"https://play.ht/api/v1/articleStatus?transcriptionId={transaction_id}&ultra=true" 85 | payload = json.dumps({ 86 | 'transcriptionId': transaction_id, 87 | 88 | }) 89 | 90 | headers = { 91 | 'Authorization': PLAYHT_API_KEY, 92 | 'X-User-ID': PLAYHT_API_USER_ID, 93 | 'Content-Type': 'application/json' 94 | } 95 | 96 | response = requests.request("GET", url, headers=headers, data=payload) 97 | print(response.text) 98 | return json.loads(response.text)['audioUrl'][0] 99 | 100 | 101 | def generate_track_on_machine(body, file_name, directory, voice="Larry", speed="0.85"): 102 | """ 103 | Generate an audio track on a local machine with the given text, voice, and speed. 104 | 105 | Args: 106 | body (str): The text content to be converted to audio. 107 | file_name (str): The name to save the generated audio file as. 108 | directory (str): The directory to save the audio file in. 109 | voice (str, optional): The voice to be used for the audio. Defaults to "Larry". 110 | speed (str, optional): The speed of the audio playback. Defaults to "0.85". 111 | 112 | Returns: 113 | None 114 | """ 115 | id = generate_ultra_track(body,voice,speed) 116 | 117 | audio_url = ultra_play_ht_get_id(id) 118 | print(audio_url) 119 | time.sleep(45) 120 | try: 121 | download_file(audio_url,file_name,directory) 122 | except: 123 | print("Issue downloading audio") 124 | 125 | 126 | if __name__ == "__main__": 127 | text = 'This was a few weeks ago. I was flying to visit my best friend across the USA FL-CA. I get on and am in the back of the plane in an aisle seat 23C. Upon arrival I see a 20 something (f) sitting in my seat so I point out "Hey sorry you are probably in the wrong seat" and show her my ticket. With an eye roll that could have sounded like she was playing Yahtzee she says "oh I\'m 24C." I look at 24C right behind her and see why she took my seat. There is a 300-400lb (f) sitting in the middle seat. I\'m a 6\'1 230lb (m) ...not ideal. After a 15 second stare down I say "well?" and she says she is \'comfortable already\' and \'not moving\' and \'wants to sleep\' blah blah. OK I see how it is....real dumb to put someone upset with you in the seat behind you...I proceeded to set a silent timer on my phone that went off every two minutes to remind myself to kick her seat, violently, and then every time the seat belt sign went off I\'d get up, grabbing the top of the seat to lift myself up pulling her seat back and forth and one time (accidental but worth) pulled her hair she put over the back of the seat. Safe to say she had lots of extra \'turbulence\' and got absolutely no sleep. There were MANY death stares and head turns. Each time I would just smile and wave. I knew she wouldn\'t say anything either because she is not even supposed to be in that seat anyways. Happy travels.' 128 | words = text.split() 129 | chunk_size = 10 130 | num_chunks = math.ceil(len(words) / chunk_size) 131 | time.sleep(400) 132 | 133 | for i in range(num_chunks): 134 | start = i * chunk_size 135 | end = (i + 1) * chunk_size 136 | chunk = " ".join(words[start:end]) 137 | filename = f"track_{i}.wav" 138 | generate_track_on_machine(chunk, filename, r"\sample") 139 | time.sleep(400) -------------------------------------------------------------------------------- /src/utils/generate_subtitles.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import argparse 3 | from typing import List, Dict, Union 4 | 5 | import moviepy.editor as mp 6 | import whisperx 7 | import pandas as pd 8 | from moviepy.video.tools.subtitles import SubtitlesClip 9 | from moviepy.editor import VideoFileClip 10 | 11 | 12 | from src.video import utils 13 | 14 | TextSegmentList = [List[Dict[str, Union[str, float]]]] 15 | 16 | 17 | def transcribe_and_align(input_path: Path, device: str = "cpu", model_type: str = "medium") -> dict: 18 | """Transcribe and align audio file. 19 | 20 | Args: 21 | input_path (Path): Path to audio file. 22 | device (str, optional): Device to use for transcription and alignment. 23 | Defaults to "cpu". 24 | model_type (str, optional): Type of model to use for transcription. 25 | Defaults to "medium". 26 | 27 | Returns: 28 | dict: Aligned transcriptions. 29 | """ 30 | model = whisperx.load_model(model_type,device) 31 | result = model.transcribe(input_path) 32 | model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device) 33 | result_aligned = whisperx.align(result["segments"], model_a, metadata, input_path, device) 34 | return result_aligned 35 | 36 | 37 | def segment_text_by_word_length( 38 | my_list: list, 39 | word_length_max: int = 5 40 | ) -> TextSegmentList: 41 | """ 42 | Segments a list of dictionaries containing text and timestamps into groups of a specified maximum word length. 43 | 44 | Args: 45 | my_list (TextSegmentList): A list of dictionaries containing 'text', 'start', and 'end' keys. 46 | word_length_max (int, optional): The maximum number of words per segment. Defaults to 5. 47 | 48 | Returns: 49 | TextSegmentList: A list of dictionaries containing the segmented text and corresponding start and end timestamps. 50 | """ 51 | if not isinstance(my_list, list): 52 | raise TypeError("Input 'my_list' must be a list of dictionaries.") 53 | 54 | if not all(isinstance(item, dict) for item in my_list): 55 | raise TypeError("Each item in 'my_list' must be a dictionary.") 56 | 57 | if not all( 58 | all(key in item for key in ["text", "start", "end"]) 59 | for item in my_list 60 | ): 61 | raise ValueError("Each dictionary in 'my_list' must have 'text', 'start', and 'end' keys.") 62 | 63 | if not isinstance(word_length_max, int) or word_length_max < 1: 64 | raise ValueError("Invalid value for 'word_length_max'. It must be a positive integer.") 65 | 66 | segmented_text = [] 67 | temp_segment = [] 68 | 69 | for item in my_list: 70 | temp_segment.append(item) 71 | if len(temp_segment) == word_length_max: 72 | segmented_text.append(temp_segment) 73 | temp_segment = [] 74 | 75 | if temp_segment: 76 | segmented_text.append(temp_segment) 77 | 78 | complete_segments = [] 79 | for segment in segmented_text: 80 | start_time = segment[0]['start'] 81 | end_time = segment[-1]['end'] 82 | text = " ".join(item['text'] for item in segment) 83 | complete_segments.append({"text": text, "start": start_time, "end": end_time}) 84 | 85 | return complete_segments 86 | 87 | def add_subtitles_to_video(input_path: str, output_path: str, word_segments: TextSegmentList) -> None: 88 | """ 89 | Add subtitles to a video file based on word segments with start and end times. 90 | 91 | Args: 92 | input_path (str): The path to the input video file. 93 | output_path (str): The path to the output video file with subtitles added. 94 | word_segments (TextSegmentList): A list of dictionaries containing 'text', 'start', and 'end' keys 95 | for each word segment. 96 | 97 | Returns: 98 | None 99 | """ 100 | text_clip_data = { 101 | 'start': [segment['start'] for segment in word_segments], 102 | 'end': [segment['end'] for segment in word_segments], 103 | 'text': [segment['text'] for segment in word_segments] 104 | } 105 | 106 | df = pd.DataFrame.from_dict(text_clip_data) 107 | 108 | movie_width, movie_height = utils.get_video_size(input_path) 109 | # Write the video file 110 | video = VideoFileClip(input_path) 111 | generator = lambda txt: mp.TextClip(txt, fontsize=80, color='black', align='center', font='P052-Bold', stroke_width=3, bg_color="white",method='caption',size=(movie_width, movie_height)) 112 | generator = lambda txt: mp.TextClip(txt, fontsize=80, color='white', align='center', font='P052-Bold', stroke_width=3, method='caption',size=(movie_width/2, movie_height),stroke_color="black",) 113 | subs = tuple(zip(tuple(zip(df['start'].values, df['end'].values)), df['text'].values)) 114 | subtitles = SubtitlesClip(subs, generator,) 115 | 116 | 117 | final_clip = mp.CompositeVideoClip([video, subtitles.set_pos(('center','center')),]) 118 | 119 | try: 120 | final_clip.write_videofile(output_path, fps=24) 121 | except OSError: 122 | Path(output_path).unlink() 123 | final_clip.write_videofile(output_path, fps=24) 124 | 125 | return output_path 126 | 127 | 128 | def main(): 129 | # Set up the argument parser 130 | parser = argparse.ArgumentParser(description="Create a webm video using an input image and audio.") 131 | parser.add_argument("input_path", type=Path, help="Path to the input audio file.") 132 | parser.add_argument("output_path", type=Path, default=None, help="Path to the output video file. If not provided, the input path will be used with a different file extension.") 133 | parser.add_argument("--device", type=str, default="cpu", help="Device to use for transcription and alignment (default: 'cpu')") 134 | parser.add_argument("--model_type", type=str, default="medium", help="Type of model to use for transcription (default: 'medium')") 135 | args = parser.parse_args() 136 | 137 | # Set the output path 138 | if args.output_path is None: 139 | output_path = args.input_path 140 | else: 141 | output_path = args.output_path 142 | 143 | input_path = args.input_path 144 | input_path = str(input_path) 145 | output_path = str(output_path) 146 | 147 | # Transcribe the audio file and align the transcript 148 | word_segments = transcribe_and_align(input_path, args.device, args.model_type) 149 | word_segments = word_segments['word_segments'] 150 | 151 | # Add the subtitles to the video 152 | add_subtitles_to_video(input_path, output_path, word_segments) 153 | 154 | if __name__ == "__main__": 155 | main() 156 | 157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /src/utils/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import re 4 | from typing import List, Dict, Any 5 | 6 | from datetime import timedelta 7 | from pathlib import Path 8 | import argparse 9 | 10 | from src.video import random_sample_clip 11 | from src.utils import generate_subtitles, text_utils 12 | from src.audio import audio_utils 13 | 14 | 15 | def combine_audio_and_video( 16 | video_path: str, 17 | audio_path: str, 18 | output_path: str) -> None: 19 | """ 20 | Combine the given audio and video files into a single output video file. 21 | 22 | Args: 23 | video_path (str): The path to the input video file. 24 | audio_path (str): The path to the input audio file. 25 | output_path (str): The path to save the combined output video file. 26 | 27 | Returns: 28 | None 29 | """ 30 | 31 | ffmpeg_cmd = [ 32 | "ffmpeg", 33 | "-i", video_path, 34 | "-i", audio_path, 35 | "-c:v", "copy", 36 | "-c:a", "aac", 37 | "-map", "0:v:0", 38 | "-map", "1:a:0", 39 | output_path, 40 | "-y" 41 | ] 42 | 43 | subprocess.run(ffmpeg_cmd, check=True) 44 | 45 | def generate_video_with_subtitles( 46 | uncensored_audio_file: str, 47 | source_video: str, 48 | swear_word_list: List[str], 49 | video_output_location: str, 50 | srtFilename: str = "", 51 | whisper_model: str = "medium") -> None: 52 | """ 53 | Generate a censored video with masked audio and subtitles. 54 | 55 | Args: 56 | uncensored_audio_file (str): The path to the uncensored audio file. 57 | source_video (str): The path to the source video file. 58 | swear_word_list (List[str]): A list of swear words to be censored. 59 | video_output_location (str): The path to save the generated video. 60 | srtFilename (str, optional): The path to save the subtitle file. If not provided, no subtitle file will be saved. 61 | whisper_model (str, optional): The Whisper ASR model type. Defaults to "medium". 62 | 63 | Returns: 64 | None 65 | """ 66 | 67 | parent_folder = os.path.dirname(video_output_location) 68 | srtFilename = os.path.join(parent_folder, srtFilename) if srtFilename else "" 69 | video_clip = Path("sample.mp4") 70 | family_friendly_audio = Path(uncensored_audio_file).with_name("uncensored.wav") 71 | 72 | #complete script generated from audio file 73 | 74 | raw_transcript = generate_subtitles.transcribe_and_align( 75 | uncensored_audio_file, 76 | model_type=whisper_model 77 | ) 78 | 79 | segments = raw_transcript['segments'] 80 | 81 | segments = audio_utils.mask_swear_segments( 82 | swear_word_list, 83 | segments 84 | ) 85 | 86 | 87 | if srtFilename: 88 | if os.path.exists(srtFilename): 89 | os.remove(srtFilename) 90 | 91 | #generate srt file from segments 92 | write_srt_file(segments, srtFilename) 93 | 94 | raw_word_segments = raw_transcript['word_segments'] 95 | 96 | #adds mask to existing script 97 | masked_script = audio_utils.mask_swear_segments( 98 | swear_word_list, 99 | raw_word_segments 100 | ) 101 | 102 | #find times when the speaker swears 103 | swear_segments = text_utils.filter_text_by_list( 104 | raw_word_segments, 105 | swear_word_list 106 | ) 107 | 108 | n_segment = generate_subtitles.segment_text_by_word_length(masked_script,) 109 | 110 | audio_utils.silence_segments( 111 | uncensored_audio_file, 112 | str(family_friendly_audio), 113 | swear_segments 114 | ) 115 | 116 | random_sample_clip.create_clip_with_matching_audio( 117 | source_video, 118 | str(family_friendly_audio), 119 | str(video_clip) 120 | ) 121 | 122 | generate_subtitles.add_subtitles_to_video( 123 | str(video_clip), 124 | video_output_location, 125 | n_segment 126 | ) 127 | 128 | #remove temp files 129 | os.remove(video_clip) 130 | os.remove(family_friendly_audio) 131 | 132 | 133 | def create_next_dir(input_directory: str) -> str: 134 | input_directory = Path(input_directory) 135 | is_absolute = input_directory.is_absolute() 136 | 137 | # pattern to match directories 138 | dir_pattern = r'story_\d+' 139 | 140 | current_directory = Path(os.getcwd()) 141 | if not is_absolute: 142 | directory_path = Path.joinpath(current_directory, input_directory) 143 | if directory_path.exists(): 144 | dirs = [d for d in os.listdir(directory_path) if re.match(dir_pattern, d)] 145 | # extract the numbers from the directory names and convert them to integers 146 | dir_numbers = [int(re.search(r'\d+', d).group()) for d in dirs] 147 | # get the maximum number 148 | next_num = max(dir_numbers) + 1 149 | # create the new directory name 150 | new_dir = f'story_{next_num}' 151 | directory_path = Path.joinpath(current_directory, input_directory, Path(new_dir)) 152 | 153 | else: 154 | directory_path = Path.joinpath(current_directory, input_directory, Path('story_1')) 155 | 156 | os.makedirs(directory_path) 157 | 158 | return directory_path 159 | 160 | def write_srt_file(segments: List[Dict[str, Any]], srt_filename: str) -> None: 161 | """ 162 | Write an SRT file from a list of video segments. 163 | 164 | This function writes the given segments into an SRT (SubRip Text) file, 165 | which is a common format for subtitles. Each segment includes start and end times 166 | and the associated text. 167 | 168 | Args: 169 | segments: A list of dictionaries representing video segments, where each 170 | dictionary includes 'start', 'end', and 'text' keys. 171 | srt_filename: The filename for the resulting SRT file. 172 | 173 | Returns: 174 | None 175 | """ 176 | 177 | for i, segment in enumerate(segments): 178 | # Convert start and end times to SRT time format (hh:mm:ss,ms) 179 | start_time = str(0)+str(timedelta(seconds=int(segment['start'])))+',000' 180 | end_time = str(0)+str(timedelta(seconds=int(segment['end'])))+',000' 181 | 182 | # Get the text associated with this segment 183 | text = segment['text'] 184 | 185 | # Create the SRT-formatted string for this segment 186 | srt_segment = f"{i+1}\n{start_time} --> {end_time}\n{text[1:] if text[0] == ' ' else text}\n\n" 187 | 188 | # Append this segment to the SRT file 189 | with open(srt_filename, 'a', encoding='utf-8') as srt_file: 190 | srt_file.write(srt_segment) 191 | 192 | 193 | if __name__ == "__main__": 194 | # swear_word_list = [*audio.audio_utils.get_swear_word_list().keys()] 195 | swear_word_list = [] 196 | parser = argparse.ArgumentParser() 197 | parser.add_argument("uncensored_audio_file", type=str, help="Path to the uncensored audio file") 198 | parser.add_argument("source_video", type=str, help="Path to the source video file") 199 | parser.add_argument("video_output_location", type=str, help="Path to the output video file") 200 | parser.add_argument("--swear_word_list", type=str, nargs="+", help="List of swear words to mask", default=swear_word_list) 201 | args = parser.parse_args() 202 | 203 | generate_video_with_subtitles(args.uncensored_audio_file, args.source_video, args.swear_word_list, args.video_output_location) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | absl-py==1.3.0 2 | accelerate==0.14.0 3 | aiohttp==3.8.3 4 | aiosignal==1.3.1 5 | alembic==1.9.1 6 | antlr4-python3-runtime==4.9.3 7 | anyascii==0.3.1 8 | anyio==3.6.2 9 | apache-beam==2.43.0 10 | appdirs==1.4.4 11 | asteroid-filterbanks==0.4.0 12 | asttokens==2.2.1 13 | astunparse==1.6.3 14 | async-generator==1.10 15 | async-timeout==4.0.2 16 | asyncer==0.0.2 17 | attrs==22.1.0 18 | audioread==3.0.0 19 | autopage==0.5.1 20 | Babel==2.11.0 21 | backcall==0.2.0 22 | backgroundremover==0.1.9 23 | backports.cached-property==1.0.2 24 | beautifulsoup4==4.11.1 25 | blis==0.7.9 26 | bs4==0.0.1 27 | cachetools==4.2.4 28 | catalogue==2.0.8 29 | certifi==2022.9.24 30 | cffi==1.15.1 31 | charset-normalizer==2.1.1 32 | click==8.1.3 33 | cliff==4.1.0 34 | cloudpickle==2.2.0 35 | cmaes==0.9.1 36 | cmd2==2.4.2 37 | colorama==0.4.6 38 | coloredlogs==15.0.1 39 | colorlog==6.7.0 40 | comm==0.1.2 41 | commandlines==0.4.1 42 | commonmark==0.9.1 43 | comtypes==1.1.14 44 | confection==0.0.3 45 | contourpy==1.0.6 46 | coqpit==0.0.17 47 | crcmod==1.7 48 | cycler==0.11.0 49 | cymem==2.0.7 50 | Cython==0.29.28 51 | darknet==0.3 52 | dataclasses==0.6 53 | datasets==2.7.0 54 | dateparser==1.1.7 55 | debugpy==1.6.5 56 | decorator==4.4.2 57 | dill==0.3.6 58 | docopt==0.6.2 59 | einops==0.3.2 60 | en-core-web-lg @ https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.4.1/en_core_web_lg-3.4.1-py3-none-any.whl 61 | en-core-web-md @ https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.5.0/en_core_web_md-3.5.0-py3-none-any.whl 62 | en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.5.0/en_core_web_sm-3.5.0-py3-none-any.whl 63 | entrypoints==0.4 64 | espeakng==1.0.2 65 | exceptiongroup==1.1.0 66 | executing==1.2.0 67 | fastapi==0.87.0 68 | fastavro==1.7.0 69 | fasteners==0.18 70 | ffmpeg-python==0.2.0 71 | filelock==3.8.0 72 | filetype==1.2.0 73 | Flask==2.2.2 74 | flatbuffers==22.11.23 75 | fonttools==4.38.0 76 | frozenlist==1.3.3 77 | fsspec==2022.11.0 78 | future==0.18.2 79 | g2pkk==0.1.2 80 | gast==0.4.0 81 | gdown==4.6.4 82 | gitdb==4.0.10 83 | GitPython==3.1.30 84 | google==3.0.0 85 | google-api-core==1.34.0 86 | google-api-python-client==2.6.0 87 | google-auth==1.35.0 88 | google-auth-httplib2==0.1.0 89 | google-auth-oauthlib==0.4.1 90 | google-drive==0.5.0 91 | Google-Images-Search==1.4.6 92 | google-pasta==0.2.0 93 | googleapis-common-protos==1.57.0 94 | greenlet==2.0.1 95 | grpcio==1.50.0 96 | gruut==2.2.3 97 | gruut-ipa==0.13.0 98 | gruut-lang-de==2.0.0 99 | gruut-lang-en==2.0.0 100 | h11==0.14.0 101 | h5py==3.7.0 102 | hdfs==2.7.0 103 | hmmlearn==0.2.8 104 | hsh==1.1.0 105 | httplib2==0.20.4 106 | huggingface-hub==0.11.0 107 | humanfriendly==10.0 108 | hydra-core==1.3.1 109 | HyperPyYAML==1.1.0 110 | idna==3.4 111 | ImageHash==4.3.1 112 | imageio==2.9.0 113 | imageio-ffmpeg==0.4.7 114 | importlib-metadata==4.13.0 115 | inflect==5.6.0 116 | ipykernel==6.20.1 117 | ipython==8.8.0 118 | itsdangerous==2.1.2 119 | jamo==0.4.1 120 | jedi==0.18.2 121 | jieba==0.42.1 122 | Jinja2==3.1.2 123 | joblib==1.2.0 124 | jsonlines==1.2.0 125 | julius==0.2.7 126 | jupyter_client==7.4.8 127 | jupyter_core==5.1.3 128 | keras==2.11.0 129 | kiwisolver==1.4.4 130 | langcodes==3.3.0 131 | libclang==14.0.6 132 | librosa==0.8.0 133 | llvmlite==0.39.1 134 | lxml==4.9.2 135 | Mako==1.2.4 136 | Markdown==3.4.1 137 | MarkupSafe==2.1.1 138 | matplotlib==3.6.2 139 | matplotlib-inline==0.1.6 140 | mecab-python3==1.0.5 141 | more-itertools==8.7.0 142 | moviepy==1.0.3 143 | mpmath==1.2.1 144 | multidict==6.0.2 145 | multiprocess==0.70.14 146 | murmurhash==1.0.9 147 | mutagen==1.46.0 148 | mwparserfromhell==0.6.4 149 | natsort==8.2.0 150 | nest-asyncio==1.5.6 151 | networkx==2.8.8 152 | nltk==3.8.1 153 | nose==1.3.7 154 | num2words==0.5.12 155 | numba==0.56.4 156 | numpy==1.23.5 157 | oauth2client==4.1.3 158 | oauthlib==3.2.2 159 | objsize==0.5.2 160 | omegaconf==2.3.0 161 | onnxruntime==1.13.1 162 | openai==0.26.4 163 | opencv-python==4.7.0.72 164 | opencv-python-headless==4.6.0.66 165 | opt-einsum==3.3.0 166 | optuna==3.0.5 167 | orjson==3.8.1 168 | outcome==1.2.0 169 | packaging==21.3 170 | pandas==1.5.1 171 | parso==0.8.3 172 | pathy==0.10.1 173 | pbr==5.11.1 174 | pickleshare==0.7.5 175 | Pillow==9.3.0 176 | platformdirs==2.6.2 177 | pooch==1.6.0 178 | praw==7.6.1 179 | prawcore==2.3.0 180 | preshed==3.0.8 181 | prettytable==3.6.0 182 | primePy==1.3 183 | proglog==0.1.10 184 | progressbar==2.5 185 | prompt-toolkit==3.0.36 186 | proto-plus==1.22.1 187 | protobuf==3.19.6 188 | psutil==5.9.4 189 | pure-eval==0.2.2 190 | pyannote.audio==2.1.1 191 | pyannote.core==4.5 192 | pyannote.database==4.1.3 193 | pyannote.metrics==3.2.1 194 | pyannote.pipeline==2.3 195 | pyarrow==9.0.0 196 | pyasn1==0.4.8 197 | pyasn1-modules==0.2.8 198 | pycparser==2.21 199 | pydantic==1.10.2 200 | pyDeprecate==0.3.2 201 | pydot==1.4.2 202 | PyDrive==1.3.1 203 | pydub==0.25.1 204 | pyfiglet==0.8.post1 205 | pygame==2.2.0 206 | Pygments==2.14.0 207 | PyMatting==1.1.8 208 | pymongo==3.13.0 209 | pynndescent==0.5.8 210 | pyparsing==3.0.9 211 | pyperclip==1.8.2 212 | pypinyin==0.48.0 213 | pypiwin32==223 214 | pyreadline3==3.4.1 215 | pysbd==0.3.4 216 | PySocks==1.7.1 217 | python-crfsuite==0.9.9 218 | python-dateutil==2.8.2 219 | python-dotenv==1.0.0 220 | python-multipart==0.0.5 221 | python-resize-image==1.1.20 222 | pytorch-lightning==1.6.5 223 | pytorch-metric-learning==1.6.3 224 | pytrends==4.8.0 225 | pyttsx3==2.90 226 | pytube==12.1.2 227 | pytz==2022.6 228 | pytz-deprecation-shim==0.1.0.post0 229 | PyWavelets==1.4.1 230 | pywin32==305 231 | PyYAML==6.0 232 | pyzmq==24.0.1 233 | regex==2022.10.31 234 | rembg==2.0.30 235 | requests==2.28.1 236 | requests-oauthlib==1.3.1 237 | resampy==0.4.2 238 | responses==0.18.0 239 | rfc3986==1.5.0 240 | rich==12.6.0 241 | rotary-embedding-torch==0.2.1 242 | rsa==4.9 243 | ruamel.yaml==0.17.21 244 | ruamel.yaml.clib==0.2.7 245 | scikit-image==0.19.3 246 | scikit-learn==1.2.0 247 | scipy==1.9.3 248 | seaborn==0.12.2 249 | selenium==4.8.1 250 | semver==2.13.0 251 | sentence-transformers==2.2.2 252 | sentencepiece==0.1.97 253 | shellingham==1.5.0.post1 254 | simplejson==3.18.1 255 | singledispatchmethod==1.0 256 | six==1.16.0 257 | sk-video==1.1.10 258 | smart-open==6.3.0 259 | smmap==5.0.0 260 | sniffio==1.3.0 261 | sortedcontainers==2.4.0 262 | SoundFile==0.10.3.post1 263 | soupsieve==2.3.2.post1 264 | spacy==3.5.0 265 | spacy-legacy==3.0.12 266 | spacy-loggers==1.0.4 267 | speake3==0.3 268 | speechbrain==0.5.13 269 | SQLAlchemy==1.4.46 270 | srsly==2.4.5 271 | stack-data==0.6.2 272 | starlette==0.21.0 273 | stevedore==4.1.1 274 | sympy==1.11.1 275 | tabulate==0.9.0 276 | tensorboard==2.11.0 277 | tensorboard-data-server==0.6.1 278 | tensorboard-plugin-wit==1.8.1 279 | tensorboardX==2.5.1 280 | tensorflow-estimator==2.11.0 281 | tensorflow-intel==2.11.0 282 | tensorflow-io-gcs-filesystem==0.28.0 283 | termcolor==1.1.0 284 | textsplit==0.5 285 | thinc==8.1.6 286 | thop==0.1.1.post2209072238 287 | threadpoolctl==3.1.0 288 | tifffile==2023.2.3 289 | tokenizers==0.13.2 290 | torch==1.13.1 291 | torch-audiomentations==0.11.0 292 | torch-pitch-shift==1.2.2 293 | torchaudio==0.13.1 294 | torchmetrics==0.11.0 295 | torchvision==0.14.1 296 | tornado==6.2 297 | TorToiSe==2.4.2 298 | tqdm==4.64.1 299 | trainer==0.0.20 300 | traitlets==5.8.1 301 | transformers==4.24.0 302 | trio==0.22.0 303 | trio-websocket==0.9.2 304 | TTS==0.10.2 305 | typer==0.7.0 306 | typing_extensions==4.4.0 307 | tzdata==2022.7 308 | tzlocal==4.2 309 | ultralytics==8.0.5 310 | umap-learn==0.5.1 311 | Unidecode==1.3.6 312 | unidic-lite==1.0.8 313 | update-checker==0.18.0 314 | uritemplate==3.0.1 315 | urllib3==1.26.6 316 | utils==1.0.1 317 | uvicorn==0.20.0 318 | waitress==2.1.2 319 | wasabi==0.10.1 320 | watchdog==2.1.9 321 | wcwidth==0.2.5 322 | websocket-client==1.4.2 323 | Werkzeug==2.2.2 324 | wget==3.2 325 | whisper @ git+https://github.com/openai/whisper.git@28769fcfe50755a817ab922a7bc83483159600a9 326 | whisperx @ git+https://github.com/m-bain/whisperx.git@857bcca238b2dad1439f53d15d3566b5371c5a54 327 | wikipedia==1.4.0 328 | Wikipedia-API==0.5.8 329 | windows-curses==2.3.1 330 | wrapt==1.14.1 331 | wsproto==1.2.0 332 | xxhash==3.1.0 333 | yarl==1.8.1 334 | YoutubeTags==1.3 335 | zipp==3.10.0 336 | zstandard==0.19.0 337 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Semi-Automated-Youtube-Channel 2 | 3 | --- 4 | 5 | ## Goal 6 | 7 | A tool to semi-automate various tasks related to managing a YouTube channel, allowing content creators to streamline their workflow and improve productivity. 8 | 9 | ## Features 10 | 11 | - utilizes Reddit's API to descover bodies of text 12 | - uses a text-to-speech api to play over a video 13 | - add audio overlay to a video file 14 | - add subtitles to video file based on the speech in the video (using whisperx) 15 | - Has the ablity to make simple youtube thumbnails based on popular youtube reddit channels 16 | - A user is able to upload videos via YouTube Data API 17 | 18 | ## Installation 19 | 20 | Follow these steps to set up the project locally: 21 | 22 | ### Windows 23 | 24 | 1. Clone the repository: `git clone https://github.com/isayahc/Semi-Automated-Youtube-Channel.git` 25 | 2. create a virtual environemnt: 26 | 1. `python -m venv venv` 27 | 2. `activate` 28 | 3. Install the required dependencies: `pip install -r requirements.txt` 29 | 4. Configure the API keys and authentication credentials in the example.env file 30 | 31 | ### Linux / OSX 32 | 33 | 1. Clone the repository: `git clone https://github.com/isayahc/Semi-Automated-Youtube-Channel.git` 34 | 2. create a virtual environemnt: 35 | 1. `python3 -m venv venv` 36 | 2. `source venv/bin/activate` 37 | 3. Install the required dependencies: `pip3 install -r requirements.txt` 38 | 4. Configure the API keys and authentication credentials in the example.env file 39 | 40 | ## API Keys 41 | 42 | ```python 43 | # Configuration for storing assets 44 | STRING_AUDIO_FILE_LOCATION=assets/audio/posts 45 | SWEAR_WORD_LIST_FILE_LOCATION_FILE_LOCATION=assets/text/swear_words.csv 46 | 47 | # API Keys 48 | # Needed for working with google 49 | GOOGLE_API_KEY= 50 | 51 | # Needed for text-to-speech 52 | ELEVENLABS_API_KEY= 53 | 54 | # Optional if you want to use reddit 55 | REDDIT_CLIENT_ID= 56 | REDDIT_CLIENT_SECRET= 57 | 58 | # Optional and will probably be removed soon 59 | PLAYHT_API_KEY= 60 | PLAYHT_API_USER_ID= 61 | 62 | PROJECT_CX= 63 | 64 | 65 | ``` 66 | 67 | ### Configure Reddit 68 | 69 | - [login here](https://www.reddit.com/prefs/apps) tomake a new app 70 | 71 | This is a sample of what to input 72 | ![ Image](https://github.com/isayahc/Semi-Automated-Youtube-Channel/blob/main/readme_assets/reddit_api_example.PNG?raw=true) 73 | 74 | ### Configure ElevenLabs 75 | 76 | - [Follow this link](https://docs.elevenlabs.io/api-reference/quick-start/authentication) 77 | 78 | ### Configure Youtube Data v3 79 | 80 | - [Follow this link](https://developers.google.com/youtube/v3/getting-started) 81 | 82 | ### Getting client_secret.json 83 | 84 | - [Instructions here](https://developers.google.com/youtube/v3/docs/videos/insert) 85 | 86 | ## System Requirements 87 | 88 | ### Python Version 89 | 90 | This software was written in Python 3.9.7. There is a possiblity there will be errors if an earlier version is used to compile. 91 | 92 | ## Usage 93 | 94 | This Python script helps to generate a censored video with masked audio and subtitles from a given uncensored audio and video file. 95 | 96 | ### Arguments 97 | 98 | The script accepts five command-line arguments: 99 | 100 | 1. `--audio_link`: The path to the uncensored audio file. (required) 101 | 2. `--vid_link`: The path to the source video file. (required) 102 | 3. `--swear_word_list`: The path to a text file that contains a list of swear words to be censored. Each swear word should be on a new line in the file. If not provided, a predefined list of common swear words will be used. (optional) 103 | 4. `--video_output`: The path to save the generated censored video. (required) 104 | 5. `--srtFilename`: The path to save the generated subtitle (srt) file. If not provided, no subtitle file will be saved. (optional) 105 | 106 | ### Running main.py 107 | 108 | You can run the script from the command line like this: 109 | 110 | ```bash 111 | python main.py --audio_link /path/to/audio/file --vid_link /path/to/video/file --video_output /path/to/output/file 112 | ``` 113 | 114 | This command will use the default list of swear words for censoring. 115 | 116 | If you want to use your own list of swear words, add the `--swear_word_list` argument: 117 | 118 | ```bash 119 | python main.py --audio_link /path/to/audio/file --vid_link /path/to/video/file --swear_word_list /path/to/swear_word_list.txt --video_output /path/to/output/file 120 | ``` 121 | 122 | To save a subtitle file, add the `--srtFilename` argument: 123 | 124 | ```bash 125 | python main.py --audio_link /path/to/audio/file --vid_link /path/to/video/file --swear_word_list /path/to/swear_word_list.txt --video_output /path/to/output/file --srtFilename /path/to/subtitle/file 126 | ``` 127 | 128 | --- 129 | 130 | ## Using the YouTube Video Upload Python Script 131 | 132 | This `video_upload.py` script provides a command-line interface (CLI) tool to upload videos to YouTube using the YouTube Data API. The script handles authentication, video upload, and retry logic for failed uploads. Here are instructions on how to use it. 133 | 134 | ### Pre-requisites 135 | 136 | Before you use this script, ensure that you have the following: 137 | 138 | 1. **OAuth 2.0 client ID and client secret:** You need to specify a `client_secret.json` file with your OAuth 2.0 client ID and client secret. You can get these from the Google Cloud Console. Ensure that you have enabled the YouTube Data API for your project. More Information in the [Documentation](https://developers.google.com/youtube/v3/docs/videos/insert) 139 | 140 | ### Running video_upload.py 141 | 142 | The script uses command line arguments to specify the details of the video to be uploaded. Here is the usage of the script: 143 | 144 | ```shell 145 | python video_upload.py --file FILE_PATH --title TITLE --description DESCRIPTION --category CATEGORY_ID --keywords "keyword1,keyword2" --privacyStatus PRIVACY_STATUS --thumbnail THUMBNAIL_PATH --madeForKids BOOLEAN_VALUE --youtubeShort BOOLEAN_VALUE 146 | ``` 147 | 148 | Replace the capitalized words with your own details: 149 | 150 | - `FILE_PATH`: This is the full path to the video file to upload. 151 | - `TITLE`: The title of the video. 152 | - `DESCRIPTION`: The description of the video. 153 | - `CATEGORY_ID`: The numeric category ID of the video. See [here](https://developers.google.com/youtube/v3/docs/videoCategories/list) for a list of category IDs. 154 | - `keyword1,keyword2`: The keywords for the video, separated by commas. 155 | - `PRIVACY_STATUS`: The privacy status of the video. Choose from 'public', 'private', or 'unlisted'. 156 | - `THUMBNAIL_PATH`: The full path to the thumbnail image file. 157 | - `BOOLEAN_VALUE` for `--madeForKids`: Specify if the video is made for kids. Use True or False. 158 | - `BOOLEAN_VALUE` for `--youtubeShort`: Specify if the video is a YouTube short. Use True or False. 159 | 160 | For example: 161 | 162 | ```shell 163 | python video_upload.py --file /path/to/video.mp4 --title "Test Video" --description "This is a test video" --category 27 --keywords "test,video" --privacyStatus private --thumbnail /path/to/thumbnail.jpg --madeForKids False --youtubeShort False 164 | ``` 165 | 166 | This command uploads the video located at `/path/to/video.mp4` with the title "Test Video", description "This is a test video", category 27, keywords "test" and "video", privacy status set to 'private', thumbnail image from `/path/to/thumbnail.jpg`, and specifies that the video is not made for kids and is not a YouTube short. 167 | 168 | Please note that the script will prompt you to authorize the request in your web browser when you run it for the first time. It is a one-time process, and the script will store the authorization credentials for future runs. 169 | 170 | ### Exception Handling 171 | 172 | The script will stop execution and print an error message if there's an issue, such as a file not being found at the specified path. 173 | 174 | ## Testing 175 | 176 | to run test input the command 177 | 178 | ```bash 179 | python -m pytest 180 | ``` 181 | 182 | ## Add later 183 | 184 | - the ability to generate metadata that optimize how a video gets found based o the script of the video 185 | 186 | ## Contributing 187 | 188 | Contributions are welcome! If you want to contribute to the project, follow these steps: 189 | 190 | 1. Fork the repository and clone it locally. 191 | 2. Create a new branch: `git checkout -b feature/your-feature-name` 192 | 3. Make your changes and commit them: `git commit -m "Add your commit message"` 193 | 4. Push the changes to your forked repository: `git push origin feature/your-feature-name` 194 | 5. Open a pull request, describing the changes you made and their purpose. 195 | 196 | ## License 197 | 198 | This project is licensed under the [Apache License](http://www.apache.org/licenses/). 199 | 200 | ## Contact 201 | 202 | For any questions, feedback, or inquiries, feel free to contact the project maintainer at [isayahculbertson@gmail.com](mailto:isayahculbertson@gmail.com). -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/images/thumbnail.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from typing import List, Dict, Optional 4 | 5 | from PIL import Image, ImageDraw, ImageFont 6 | from rembg import remove 7 | 8 | 9 | def create_thumbnail(segmented_image:str,text:List[Dict],output_image="thumbnail.png"): 10 | # load the background image 11 | background_dimensions = (1280, 720) 12 | background = Image.new("RGB", background_dimensions, (0, 0, 0)) 13 | 14 | # load the image to be placed on the right 15 | image = Image.open(segmented_image) 16 | 17 | # resize the image to fit on the background 18 | max_width = 6000 19 | max_height = background.height 20 | 21 | width, height = image.size 22 | if width > max_width or height > max_height: 23 | ratio = min(max_width/width, max_height/height) 24 | new_width = int(width * ratio) 25 | new_height = int(height * ratio) 26 | image = image.resize((new_width, new_height)) 27 | 28 | 29 | padding = 0 30 | 31 | image_y = background.height - image.height - padding 32 | x_edge = background.width - image.width 33 | 34 | # place the image on the right side of the background 35 | background.paste(image, (x_edge, image_y)) 36 | 37 | # create a draw object 38 | draw = ImageDraw.Draw(background) 39 | 40 | # iterate over the list of text objects and draw them on the left side of the background 41 | for item in text: 42 | # get the text and formatting information 43 | text = item["text"] 44 | color = item["color"] 45 | spacingTop = item["spacingTop"] 46 | size = item["size"] 47 | font_location = item['font'] 48 | 49 | # set the font and color 50 | draw.text((30, spacingTop), text, font=ImageFont.truetype(font_location, size), fill=color,) 51 | 52 | # save the resulting image 53 | background.save(output_image) 54 | 55 | def create_thumbnail(segmented_image: str, 56 | text_items: List[Dict[str, object]], 57 | output_image: Optional[str] = None, 58 | text_x_pos: int = 0, 59 | default_font_location: str = "arial.ttf", 60 | y_spacing: int = 1) -> Image.Image: 61 | """ 62 | Create a thumbnail image from a background, segmented image, and text. 63 | 64 | This function generates a thumbnail image by combining a provided segmented image with text 65 | drawn on the left side. The text_items parameter is a list of dictionaries, each containing 66 | the text to be drawn and its formatting options. The resulting image can be saved to disk 67 | or returned as a PIL.Image object. 68 | 69 | Args: 70 | segmented_image (str): Path to the segmented image file. 71 | text_items (List[Dict[str, object]]): A list of dictionaries containing the text and 72 | its formatting options. Each dictionary should have the following keys: 73 | - "text": The text string to be drawn. 74 | - "color": The text color in RGB format. 75 | - "size": The font size. 76 | - "font" (optional): Path to the font file. Defaults to arial.ttf. 77 | - "y_spacing" (optional): Vertical spacing between text lines. Defaults to 1. 78 | output_image (Optional[str], optional): Path to save the output image. If not provided, 79 | the function will return the generated image as a PIL.Image object. 80 | text_x_pos (int, optional): The x-coordinate of the starting position for the text. 81 | Defaults to 0. 82 | default_font_location (str, optional): Path to the default font file. Defaults to "arial.ttf". 83 | y_spacing (int, optional): Default vertical spacing between text lines. Defaults to 1. 84 | 85 | Returns: 86 | Image.Image: The generated thumbnail image. Returned only if output_image is not provided. 87 | """ 88 | 89 | # Load the background image 90 | background_dimensions = (1280, 720) 91 | background_color = (0, 0, 0) 92 | background = Image.new("RGB", background_dimensions, background_color) 93 | 94 | # Load the image to be placed on the right 95 | image = Image.open(segmented_image) 96 | 97 | # Resize the image to fit on the background 98 | max_width = 6000 # This value seems too high for a thumbnail 99 | max_height = background.height 100 | 101 | width, height = image.size 102 | if width > max_width or height > max_height: 103 | ratio = min(max_width/width, max_height/height) 104 | new_width = int(width * ratio) 105 | new_height = int(height * ratio) 106 | image = image.resize((new_width, new_height)) 107 | 108 | # Calculate the y-coordinate of the image 109 | image_y = background.height - image.height 110 | 111 | # Place the image on the right side of the background 112 | background.paste(image, (background.width - image.width, image_y)) 113 | 114 | # Create a draw object 115 | draw = ImageDraw.Draw(background) 116 | 117 | # Set default text position 118 | text_y_pos = 0 119 | 120 | # Iterate over the list of text objects and draw them on the left side of the background 121 | for item in text: 122 | # Get the text and formatting information 123 | text = item["text"] 124 | color = item["color"] 125 | y_spacing = item.get("y_spacing", y_spacing) 126 | size = item["size"] 127 | font_location = item.get("font", default_font_location) 128 | 129 | # Set the font and color 130 | font = ImageFont.truetype(font_location, size) 131 | text_width, text_height = draw.textsize(text, font=font) 132 | draw.text((text_x_pos, text_y_pos + y_spacing), text, font=font, fill=color) 133 | 134 | # Update the text position 135 | text_y_pos += text_height + y_spacing 136 | 137 | # Save the resulting image 138 | if output_image: 139 | background.save(output_image) 140 | else: 141 | return background 142 | 143 | def segment_image(input_path:str,output_path:str): 144 | input = Image.open(input_path) 145 | output = remove(input) 146 | output.save(output_path) 147 | 148 | 149 | def crop_png(input_data: str, output_data: str) -> None: 150 | """ 151 | Crop a PNG image to the non-transparent area and save it to a file. 152 | 153 | Args: 154 | input_data (str): The path to the input image file. 155 | output_data (str): The path to save the cropped image file. 156 | 157 | Returns: 158 | None 159 | """ 160 | # Open the image 161 | image = Image.open(input_data) 162 | 163 | # Get the size of the image 164 | width, height = image.size 165 | 166 | # Get the alpha channel of the image 167 | alpha = image.split()[-1] 168 | 169 | # Find the bounding box of the non-transparent part of the image 170 | bbox = alpha.getbbox() 171 | 172 | # Crop the image to the bounding box 173 | cropped_image = image.crop(bbox) 174 | 175 | # Save the cropped image 176 | cropped_image.save(output_data) 177 | 178 | 179 | def crop_transparent(image_path: str, output_path: str): 180 | """Crop a transparent image and save to a file. 181 | 182 | Args: 183 | image_path (str): The path to the input image. 184 | output_path (str): The path to save the cropped image. 185 | 186 | Returns: 187 | None 188 | """ 189 | # Open the image 190 | image = Image.open(image_path) 191 | 192 | # Get the size of the image 193 | width, height = image.size 194 | 195 | # Find the dimensions of the non-transparent part of the image 196 | left, top, right, bottom = width, height, 0, 0 197 | for x in range(width): 198 | for y in range(height): 199 | alpha = image.getpixel((x,y))[3] 200 | if alpha != 0: 201 | left = min(left, x) 202 | top = min(top, y) 203 | right = max(right, x) 204 | bottom = max(bottom, y) 205 | 206 | # Crop the image to the non-transparent part 207 | image = image.crop((left, top, right, bottom)) 208 | 209 | # Save the cropped image 210 | image.save(output_path) 211 | 212 | 213 | 214 | def download_image(url: str, directory: str = ".") -> str: 215 | """ 216 | Download an image from a URL and save it to a directory. 217 | 218 | Args: 219 | url (str): The URL of the image to download. 220 | directory (str): The directory to save the image in. Defaults to the current directory. 221 | 222 | Returns: 223 | str: The file path of the downloaded image if successful, else None. 224 | """ 225 | response = requests.get(url) 226 | if response.status_code == 200: 227 | filename = os.path.basename(url) 228 | filepath = os.path.join(directory, filename) 229 | with open(filepath, "wb") as f: 230 | f.write(response.content) 231 | return filepath 232 | else: 233 | return None 234 | 235 | 236 | def convert_to_png(sample:str) -> str: 237 | """ 238 | Converts the input image file to PNG format if it is a JPEG file. 239 | 240 | Args: 241 | sample (str): The path to the input image file. 242 | 243 | Returns: 244 | str: The path to the output image file. If the input image file is already in PNG format, returns the original file path. 245 | """ 246 | img = Image.open(sample) 247 | if img.format == "JPEG": 248 | img = img.convert("RGBA") 249 | os.remove(sample) 250 | # get the base file name and old extension 251 | base_name, old_extension = os.path.splitext(sample) 252 | 253 | # replace the old extension with ".png" 254 | new_file_path = base_name + ".png" 255 | img.save(new_file_path) 256 | else: 257 | new_file_path = sample 258 | return new_file_path 259 | 260 | 261 | if __name__ == '__main__': 262 | 263 | data = "https://th.bing.com/th/id/R.3d79e075f692870894fc41d6304eb4f2?rik=GfJgXZ5%2b5MJCVQ&riu=http%3a%2f%2fwww.pixelstalk.net%2fwp-content%2fuploads%2f2016%2f05%2fReally-Cool-Image.jpg" 264 | data = "https://www.lockheedmartin.com/content/dam/lockheed-martin/eo/photo/news/features/2021/ai/ai-small-1920.jpg.pc-adaptive.768.medium.jpeg" 265 | data = "https://images.girlslife.com/posts/009/9250/shutterstock_406983616.jpg" 266 | data = "https://www.aaa.com/AAA/common/AAR/images/deice1.png" 267 | 268 | 269 | sample = download_image(data) 270 | ff = convert_to_png(sample) 271 | # segment_image(ff,ff) 272 | # crop_png(ff,ff) 273 | 274 | 275 | 276 | # default_font_location = r"C:\Users\isaya\Downloads\Press_Start_2P\PressStart2P-Regular.ttf" 277 | # default_font_location = "arial.ttf" 278 | # text = [ 279 | # {"text": "r/Petty revenge", "color" : (255,255,255), "spacingTop": 0, "size" : 90, "font": default_font_location}, 280 | # {"text": "", "color" : (255,0,0), "spacingTop": 90, "size" : 90, "font": default_font_location}, 281 | # {"text": "taken? I'll go to ", "color" : (255,255,255), "spacingTop": 180, "size" : 90, "font": default_font_location}, 282 | # {"text": "first class", "color" : (255, 215, 0), "spacingTop": 270, "size" : 90, "font": default_font_location} 283 | # ] 284 | 285 | # input_data = r"c:\Users\isaya\code_examples\Machine_Learning\img_manipulation\japanese_robot.jpg" 286 | # output_data = r"c:\Users\isaya\code_examples\Machine_Learning\img_manipulation\_robot.png" 287 | # segment_image(input_data,output_data) 288 | # crop_png(output_data,output_data) 289 | 290 | # default_font_location = "arial.ttf" 291 | # ff = r"c:\Users\isaya\code_examples\Machine_Learning\img_manipulation\toy_boat_0.jpg" 292 | # text = [ 293 | # {"text": "r/Petty revenge", "color" : (255,255,255), "size" : 90, "font": default_font_location}, 294 | # {"text": "Allow my seat to be", "color" : (255,0,0), "size" : 90, "font": default_font_location}, 295 | # {"text": "taken? I'll go to ", "color" : (255,255,255), "size" : 90, "font": default_font_location}, 296 | # {"text": "first class", "color" : (255,255,255), "size" : 90, "font": default_font_location}, 297 | # ] 298 | # create_thumbnail(ff, text, "thumbnail.png",text_x_pos=20) 299 | 300 | 301 | # create_thumbnail(ff,text,"fuck.png") 302 | # create_thumbnail(output_data,text,"fuck.png") --------------------------------------------------------------------------------