├── src ├── __init__.py ├── helpers │ ├── __init__.py │ ├── cli.py │ ├── video_processing.py │ ├── youtube.py │ └── twitch.py ├── secrets │ ├── twitch_creds_template.json │ └── youtube_creds_template.md ├── config.py └── clip_captain.py ├── thumbnails └── placeholder.txt ├── static ├── beach.jpeg ├── scripts.js └── main.css ├── assets ├── frontend.png └── tvstatictransition.mp4 ├── requirements.txt ├── .gitignore ├── README.md ├── templates ├── process.html └── index.html └── .pylintrc /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /thumbnails/placeholder.txt: -------------------------------------------------------------------------------- 1 | allows tracking of thumbnails folder -------------------------------------------------------------------------------- /static/beach.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NikoRaisanen/clip-captain/HEAD/static/beach.jpeg -------------------------------------------------------------------------------- /assets/frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NikoRaisanen/clip-captain/HEAD/assets/frontend.png -------------------------------------------------------------------------------- /assets/tvstatictransition.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NikoRaisanen/clip-captain/HEAD/assets/tvstatictransition.mp4 -------------------------------------------------------------------------------- /src/secrets/twitch_creds_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_secret": "xxxxxxxxxxxxxxxxxx", 3 | "client_id": "xxxxxxxxxxxxxxxxxx" 4 | } -------------------------------------------------------------------------------- /src/secrets/youtube_creds_template.md: -------------------------------------------------------------------------------- 1 | Go to this link, download your oauth credentials, and paste the .json file into this directory 2 | 3 | See: https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | # Path to youtube api keys 5 | YT_SECRETS_PATH = os.path.join(os.getcwd(), 'src', 'secrets', 'youtube_creds.json') 6 | 7 | # Path to twitch api keys 8 | TWITCH_SECRETS_PATH = os.path.join(os.getcwd(), 'src', 'secrets', 'twitch_creds.json') 9 | 10 | # Path to clip download directory 11 | CLIP_PATH = os.path.join(os.getcwd(), 'clips') 12 | 13 | # Path to default transition media 14 | DEFAULT_TRANSITION_PATH = os.path.join(os.getcwd(), 'assets', 'tvstatictransition.mp4') 15 | 16 | # Path to final video 17 | FINAL_VID_PATH = os.path.join(os.getcwd(), 'finalVideos') 18 | 19 | # Language options of twitch clips 20 | VALID_LANGUAGES = ['en', 'es', 'fr', 'ru', 'ko', 'sv', 'pt', 'da'] 21 | 22 | # Max number of clips to use from a single creator. Set to None to use all clips 23 | CLIPS_PER_CREATOR = 1 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.9.3 2 | cachetools==4.2.2 3 | certifi==2021.5.30 4 | chardet==4.0.0 5 | click==8.0.1 6 | decorator==4.4.2 7 | Flask==2.0.1 8 | google==3.0.0 9 | google-api-core==1.30.0 10 | google-api-python-client==2.12.0 11 | google-auth==1.32.1 12 | google-auth-httplib2==0.1.0 13 | google-auth-oauthlib==0.4.4 14 | googleapis-common-protos==1.53.0 15 | httplib2==0.19.1 16 | idna==2.10 17 | imageio==2.9.0 18 | imageio-ffmpeg==0.4.4 19 | itsdangerous==2.0.1 20 | Jinja2==3.0.1 21 | MarkupSafe==2.0.1 22 | moviepy==1.0.3 23 | numpy==1.21.0 24 | oauthlib==3.1.1 25 | packaging==21.0 26 | Pillow==8.3.0 27 | proglog==0.1.9 28 | protobuf==3.17.3 29 | pyasn1==0.4.8 30 | pyasn1-modules==0.2.8 31 | pyparsing==2.4.7 32 | pytz==2021.1 33 | requests==2.25.1 34 | requests-oauthlib==1.3.0 35 | rsa==4.7.2 36 | selenium==3.141.0 37 | six==1.16.0 38 | soupsieve==2.2.1 39 | tqdm==4.61.2 40 | uritemplate==3.0.1 41 | urllib3==1.26.6 42 | Werkzeug==2.0.1 43 | -------------------------------------------------------------------------------- /static/scripts.js: -------------------------------------------------------------------------------- 1 | function get_status() { 2 | currentText = document.getElementById('userUpdate').innerText 3 | fetch('/status') 4 | .then(function (response) { 5 | return response.json(); // But parse it as JSON this time 6 | }) 7 | .then(function (json) { 8 | console.log(JSON.stringify(json.status)) 9 | console.log("tHIS IS CURRENT TEXT:" + currentText) 10 | document.getElementById('userUpdate').innerText = JSON.stringify(json.status) 11 | 12 | }) 13 | setTimeout(get_status, 5000); 14 | } 15 | 16 | var slider = document.getElementById("myRange"); 17 | var output = document.getElementById("numClips"); 18 | output.innerHTML = "Number of clips to use: " + String(slider.value) + ""; 19 | 20 | // Dynamically update slider value 21 | slider.oninput = function() { 22 | output.innerHTML = "Number of clips to use: " + String(this.value) + "" 23 | } -------------------------------------------------------------------------------- /src/helpers/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | def start(): 5 | """Get params from cli""" 6 | # Params for Twitch api and clip download 7 | parser = argparse.ArgumentParser(description="Automatically create and upload a gaming youtube video") 8 | parser.add_argument("-g", "--game", type=str, help="Name of target game", required=True) 9 | parser.add_argument("-pd", "--past-days", type=int, help="Get clips from the past X days", required=False, default=7) 10 | parser.add_argument("-n", "--num-clips", type=int, help="Number of clips to be used in your video", required=False, default=20) 11 | parser.add_argument("-f", "--first", type=int, help="Fetch X clips on each Twitch api call", required=False, default=50) 12 | parser.add_argument("-l", "--language", type=str, help="Language of fetched clips", required=False, default='en') 13 | 14 | # Params for Youtube Video 15 | parser.add_argument("-vt", "--video-title", type=str, help="Title of your Youtube video", required=True) 16 | parser.add_argument("-tn", "--thumbnail", type=str, help="Path to the thumbnail of your video", required=False, default='') 17 | parser.add_argument("-t", "--tags", nargs='+', help="Tags for your Youtube video", required=False, default=None) 18 | parser.add_argument("-d", "--description", type=str, help="Description for -your Youtube video", required=False, default=None) 19 | parser.add_argument("-p", "--privacy-status", type=str, help="Privacy status for your Youtube Video", required=False, default='private', choices=['unlisted', 'private', 'public']) 20 | parser.add_argument("-tm", "--transition-media", type=str, help="Path to transition media for combining clips", required=False, default=None) 21 | 22 | args = parser.parse_args() 23 | return args 24 | -------------------------------------------------------------------------------- /src/helpers/video_processing.py: -------------------------------------------------------------------------------- 1 | """Module that handles video and text generation""" 2 | import os 3 | from moviepy.editor import * 4 | from config import CLIP_PATH, DEFAULT_TRANSITION_PATH, FINAL_VID_PATH 5 | 6 | 7 | def add_creator_watermark(clips, game_name): 8 | """Add creator watermark to their respective video(s)""" 9 | download_path = os.path.join(CLIP_PATH, game_name) 10 | videos = [] 11 | # set up the composite video that includes streamer name 12 | for clip in clips: 13 | # Add text below: 14 | video = VideoFileClip(os.path.join(download_path, clip.filename), target_resolution=(1080,1920)) 15 | txt_clip = TextClip(clip.streamer_name, fontsize = 60, color = 'white',stroke_color='black',stroke_width=2, font="Fredoka-One") 16 | txt_clip = txt_clip.set_pos((0.8, 0.9), relative=True).set_duration(video.duration) 17 | video = CompositeVideoClip([video, txt_clip]).set_duration(video.duration) 18 | videos.append(video) 19 | return videos 20 | 21 | 22 | def create_video(videos, transition, filename): 23 | """Stitch videos together and save the video to disk""" 24 | if not transition: 25 | transition = DEFAULT_TRANSITION_PATH 26 | 27 | print(f'Using {transition} as the transitioning media') 28 | # Make transition clip 1 second long and halve the volume 29 | transition = VideoFileClip(transition).fx(afx.volumex, 0.5) 30 | transition = transition.subclip(0, -1) 31 | print('Beginning to concatenate video clips...') 32 | final = concatenate_videoclips(videos, transition=transition, method='compose') 33 | 34 | if not os.path.exists(FINAL_VID_PATH): 35 | os.mkdir('finalVideos') 36 | # TODO: explore using more threads to speed up the process 37 | final.write_videofile(os.path.join(FINAL_VID_PATH, filename), fps=60, bitrate="6000k", threads=8) 38 | return os.path.join(FINAL_VID_PATH, filename) 39 | 40 | 41 | def finalize_video(clips, transition, filename, game_name): 42 | """Add watermark to clips, stitch videos together with transition""" 43 | videos = add_creator_watermark(clips, game_name) 44 | return create_video(videos, transition, filename) 45 | -------------------------------------------------------------------------------- /src/helpers/youtube.py: -------------------------------------------------------------------------------- 1 | """Module that handles user consent and youtube api""" 2 | import socket 3 | from moviepy.editor import * 4 | from googleapiclient.http import MediaFileUpload 5 | from googleapiclient.discovery import build 6 | from google_auth_oauthlib.flow import InstalledAppFlow 7 | from config import YT_SECRETS_PATH 8 | 9 | 10 | socket.setdefaulttimeout(100000) 11 | 12 | 13 | def get_authenticated_service(): 14 | """Local oauth flow, returns authenticated youtube service object""" 15 | CLIENT_SECRET_FILE = YT_SECRETS_PATH 16 | API_NAME = 'youtube' 17 | API_VERSION = 'v3' 18 | SCOPES = ['https://www.googleapis.com/auth/youtube.upload'] 19 | # Authenticate on each request for web version 20 | flow = InstalledAppFlow.from_client_secrets_file( 21 | CLIENT_SECRET_FILE, 22 | scopes=SCOPES) 23 | print('Select the google account where you would like to upload the video') 24 | credentials = flow.run_local_server() 25 | 26 | service = build(API_NAME, API_VERSION, credentials=credentials) 27 | return service 28 | 29 | 30 | def upload_video(service, video): 31 | """Takes in authenticated yt service and custom video class, uploads video to youtube""" 32 | if not video.description: 33 | video.set_default_description() 34 | 35 | request_body = { 36 | 'snippet': { 37 | 'title': video.title, 38 | 'categoryId': '20', 39 | 'description': video.description, 40 | 'tags': video.tags, 41 | 'defaultLanguage': video.language, 42 | }, 43 | 'status': { 44 | 'privacyStatus': video.privacy_status, 45 | 'selfDeclaredMadeForKids': False 46 | }, 47 | 'notifySubscribers': False 48 | } 49 | file = MediaFileUpload(video.filename) 50 | print(f'Uploading the following file: {video.filename}') 51 | 52 | 53 | print(f'Uploading video with the following information...\n{request_body}') 54 | response_upload = service.videos().insert( 55 | part = 'snippet,status', 56 | body = request_body, 57 | media_body = file 58 | ).execute() 59 | print(f'response_upload: {response_upload}') 60 | # Set thumbnail if valid file 61 | try: 62 | service.thumbnails().set( 63 | videoId=response_upload.get('id'), 64 | media_body=MediaFileUpload(video.thumbnail) 65 | ).execute() 66 | except FileNotFoundError: 67 | print(f'{video.thumbnail} could not be found, not updating thumbnail...') 68 | 69 | print('Upload complete!') 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Private stuff, credentials, etc. 141 | /clips/* 142 | twitch_creds.json 143 | youtube_creds.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automating Youtube Gaming Highlight Channels (Work in Progress) 2 |  3 | ## Why was this application created? 4 | 5 | On Youtube you can find hundreds of channels that create gaming compilation videos which receive millions of views. With such viewcounts comes the opportunity to monetize through ad revenue and sponsor activations. 6 | Many of these videos are quite basic in nature, the format is as follows: 7 | 8 | 9 | * Get 5+ minutes of gaming footage, highlights, etc. 10 | * Overlay the name of the original content creator over the video 11 | * Stitch highlights together with a common transition 12 | * **Profit $$$** 13 | 14 | 15 | With such a simple formula to create this type of video, there must be a way to automate the entire process... 16 | 17 | ## What this application does 18 | 1. Queries Twitch Clips API for the X most popular clips (videos) for the selected game 19 | 2. Downloads these clips into a directory called clips 20 | 3. A text overlay is added to each clip which contains the the content creator's name 21 | 4. These updated clips are edited together with a common "tv static" transition 22 | 5. The resulting video is written into a directory called finalVideos 23 | 6. The final video is uploaded to YOUR Youtube channel according to the settings specified in the front-end application 24 | 25 | Sample of what the resulting videos look like: 26 | **[link to video PoC]** 27 | 28 | All of the content for this video was automatically scraped, edited, and uploaded by filling out a simple form and authenticating with a Google account. 29 | 30 | ## How it works 31 | The project wasn't as straight-forward as it may seem, in the below video I take you through a deeper dive on how the program works under the hood 32 | **[add link to YT video that I will make]** 33 | 34 | ## Crediting the content owners 35 | This program takes video content from many creators and consolidates it into a single video. Credit to the original creator is given in the following ways: 36 | 37 | * A "watermark" with the creator's name is applied on the bottom right of the video while their clip is being played 38 | * The application's auto-generated description provides a link to the Twitch channels of all creators that appeared in the video 39 | 40 | ### Run this program locally 41 | This program will **not** work if you only clone it. You will need to get API keys for Twitch and Youtube Data API v3. Upon receiving these API keys you can download the respective json files and name them "twitchCreds.json" and "client_secret.json." Put these 2 json files in the root project directory and your API keys will be used to execute the program! 42 | ### Use this program as a SaaS model 43 | I deployed this application onto Google App Engine using a production-quality web server (gunicorn) so that it can be used as a SaaS. 44 | Clicking the "Upload Video" on the GCP-hosted application does not work at the moment. I configured the OAuth Flow to work when the program is accessed locally, the OAuth integration for remote connections is a WIP 45 | -------------------------------------------------------------------------------- /templates/process.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |