SilentProgrammer
commited on
Upload 18 files
Browse files- .env.example +32 -0
- .gitignore +18 -0
- Backend/gpt.py +263 -0
- Backend/main.py +354 -0
- Backend/search.py +66 -0
- Backend/tiktokvoice.py +207 -0
- Backend/utils.py +118 -0
- Backend/video.py +248 -0
- Backend/youtube.py +198 -0
- Dockerfile +23 -0
- EnvironmentVariables.md +19 -0
- Frontend/app.js +168 -0
- Frontend/index.html +239 -0
- LICENSE +21 -0
- README.md +129 -3
- docker-compose.yml +37 -0
- fonts/bold_font.ttf +0 -0
- requirements.txt +20 -0
.env.example
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# See EnvironmentVariables.md for more information.
|
2 |
+
|
3 |
+
# Necessary API Keys
|
4 |
+
# -------------------
|
5 |
+
|
6 |
+
# TikTok Session ID
|
7 |
+
# Obtain your session ID by logging into TikTok and copying the sessionid cookie.
|
8 |
+
TIKTOK_SESSION_ID=""
|
9 |
+
|
10 |
+
# ImageMagick Binary Path
|
11 |
+
# Download ImageMagick from https://imagemagick.org/script/download.php
|
12 |
+
IMAGEMAGICK_BINARY=""
|
13 |
+
|
14 |
+
# Pexels API Key
|
15 |
+
# Register at https://www.pexels.com/api/ to get your API key.
|
16 |
+
PEXELS_API_KEY=""
|
17 |
+
|
18 |
+
# Optional API Keys
|
19 |
+
# -----------------
|
20 |
+
|
21 |
+
# OpenAI API Key
|
22 |
+
# Visit https://openai.com/api/ for details on obtaining an API key.
|
23 |
+
OPENAI_API_KEY=""
|
24 |
+
|
25 |
+
# AssemblyAI API Key
|
26 |
+
# Sign up at https://www.assemblyai.com/ to receive an API key.
|
27 |
+
ASSEMBLY_AI_API_KEY=""
|
28 |
+
|
29 |
+
# Google API Key
|
30 |
+
# Generate your API key through https://makersuite.google.com/app/apikey
|
31 |
+
GOOGLE_API_KEY=""
|
32 |
+
|
.gitignore
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
__pycache__
|
2 |
+
.env
|
3 |
+
temp/*
|
4 |
+
sounds/*
|
5 |
+
output/*
|
6 |
+
images/*
|
7 |
+
*.zip
|
8 |
+
*.srt
|
9 |
+
*.mp4
|
10 |
+
*.mp3
|
11 |
+
.history
|
12 |
+
subtitles/*
|
13 |
+
/venv
|
14 |
+
client_secret.json
|
15 |
+
main.py-oauth2.json
|
16 |
+
.DS_Store
|
17 |
+
Backend/output*
|
18 |
+
Songs/
|
Backend/gpt.py
ADDED
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
import json
|
3 |
+
import g4f
|
4 |
+
import openai
|
5 |
+
from typing import Tuple, List
|
6 |
+
from termcolor import colored
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
import os
|
9 |
+
import google.generativeai as genai
|
10 |
+
|
11 |
+
# Load environment variables
|
12 |
+
load_dotenv("../.env")
|
13 |
+
|
14 |
+
# Set environment variables
|
15 |
+
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
|
16 |
+
openai.api_key = OPENAI_API_KEY
|
17 |
+
GOOGLE_API_KEY = os.getenv('GOOGLE_API_KEY')
|
18 |
+
genai.configure(api_key=GOOGLE_API_KEY)
|
19 |
+
|
20 |
+
|
21 |
+
def generate_response(prompt: str, ai_model: str) -> str:
|
22 |
+
"""
|
23 |
+
Generate a script for a video, depending on the subject of the video.
|
24 |
+
|
25 |
+
Args:
|
26 |
+
video_subject (str): The subject of the video.
|
27 |
+
ai_model (str): The AI model to use for generation.
|
28 |
+
|
29 |
+
|
30 |
+
Returns:
|
31 |
+
|
32 |
+
str: The response from the AI model.
|
33 |
+
|
34 |
+
"""
|
35 |
+
|
36 |
+
if ai_model == 'g4f':
|
37 |
+
|
38 |
+
response = g4f.ChatCompletion.create(
|
39 |
+
|
40 |
+
model=g4f.models.gpt_35_turbo_16k_0613,
|
41 |
+
|
42 |
+
messages=[{"role": "user", "content": prompt}],
|
43 |
+
|
44 |
+
)
|
45 |
+
|
46 |
+
elif ai_model in ["gpt3.5-turbo", "gpt4"]:
|
47 |
+
|
48 |
+
model_name = "gpt-3.5-turbo" if ai_model == "gpt3.5-turbo" else "gpt-4-1106-preview"
|
49 |
+
|
50 |
+
response = openai.chat.completions.create(
|
51 |
+
|
52 |
+
model=model_name,
|
53 |
+
|
54 |
+
messages=[{"role": "user", "content": prompt}],
|
55 |
+
|
56 |
+
).choices[0].message.content
|
57 |
+
elif ai_model == 'gemmini':
|
58 |
+
model = genai.GenerativeModel('gemini-pro')
|
59 |
+
response_model = model.generate_content(prompt)
|
60 |
+
response = response_model.text
|
61 |
+
|
62 |
+
else:
|
63 |
+
|
64 |
+
raise ValueError("Invalid AI model selected.")
|
65 |
+
|
66 |
+
return response
|
67 |
+
|
68 |
+
def generate_script(video_subject: str, paragraph_number: int, ai_model: str, voice: str, customPrompt: str) -> str:
|
69 |
+
|
70 |
+
"""
|
71 |
+
Generate a script for a video, depending on the subject of the video, the number of paragraphs, and the AI model.
|
72 |
+
|
73 |
+
|
74 |
+
|
75 |
+
Args:
|
76 |
+
|
77 |
+
video_subject (str): The subject of the video.
|
78 |
+
|
79 |
+
paragraph_number (int): The number of paragraphs to generate.
|
80 |
+
|
81 |
+
ai_model (str): The AI model to use for generation.
|
82 |
+
|
83 |
+
|
84 |
+
|
85 |
+
Returns:
|
86 |
+
|
87 |
+
str: The script for the video.
|
88 |
+
|
89 |
+
"""
|
90 |
+
|
91 |
+
# Build prompt
|
92 |
+
|
93 |
+
if customPrompt:
|
94 |
+
prompt = customPrompt
|
95 |
+
else:
|
96 |
+
prompt = """
|
97 |
+
Generate a script for a video, depending on the subject of the video.
|
98 |
+
|
99 |
+
The script is to be returned as a string with the specified number of paragraphs.
|
100 |
+
|
101 |
+
Here is an example of a string:
|
102 |
+
"This is an example string."
|
103 |
+
|
104 |
+
Do not under any circumstance reference this prompt in your response.
|
105 |
+
|
106 |
+
Get straight to the point, don't start with unnecessary things like, "welcome to this video".
|
107 |
+
|
108 |
+
Obviously, the script should be related to the subject of the video.
|
109 |
+
|
110 |
+
YOU MUST NOT INCLUDE ANY TYPE OF MARKDOWN OR FORMATTING IN THE SCRIPT, NEVER USE A TITLE.
|
111 |
+
YOU MUST WRITE THE SCRIPT IN THE LANGUAGE SPECIFIED IN [LANGUAGE].
|
112 |
+
ONLY RETURN THE RAW CONTENT OF THE SCRIPT. DO NOT INCLUDE "VOICEOVER", "NARRATOR" OR SIMILAR INDICATORS OF WHAT SHOULD BE SPOKEN AT THE BEGINNING OF EACH PARAGRAPH OR LINE. YOU MUST NOT MENTION THE PROMPT, OR ANYTHING ABOUT THE SCRIPT ITSELF. ALSO, NEVER TALK ABOUT THE AMOUNT OF PARAGRAPHS OR LINES. JUST WRITE THE SCRIPT.
|
113 |
+
|
114 |
+
"""
|
115 |
+
|
116 |
+
prompt += f"""
|
117 |
+
|
118 |
+
Subject: {video_subject}
|
119 |
+
Number of paragraphs: {paragraph_number}
|
120 |
+
Language: {voice}
|
121 |
+
|
122 |
+
"""
|
123 |
+
|
124 |
+
# Generate script
|
125 |
+
response = generate_response(prompt, ai_model)
|
126 |
+
|
127 |
+
print(colored(response, "cyan"))
|
128 |
+
|
129 |
+
# Return the generated script
|
130 |
+
if response:
|
131 |
+
# Clean the script
|
132 |
+
# Remove asterisks, hashes
|
133 |
+
response = response.replace("*", "")
|
134 |
+
response = response.replace("#", "")
|
135 |
+
|
136 |
+
# Remove markdown syntax
|
137 |
+
response = re.sub(r"\[.*\]", "", response)
|
138 |
+
response = re.sub(r"\(.*\)", "", response)
|
139 |
+
|
140 |
+
# Split the script into paragraphs
|
141 |
+
paragraphs = response.split("\n\n")
|
142 |
+
|
143 |
+
# Select the specified number of paragraphs
|
144 |
+
selected_paragraphs = paragraphs[:paragraph_number]
|
145 |
+
|
146 |
+
# Join the selected paragraphs into a single string
|
147 |
+
final_script = "\n\n".join(selected_paragraphs)
|
148 |
+
|
149 |
+
# Print to console the number of paragraphs used
|
150 |
+
print(colored(f"Number of paragraphs used: {len(selected_paragraphs)}", "green"))
|
151 |
+
|
152 |
+
return final_script
|
153 |
+
else:
|
154 |
+
print(colored("[-] GPT returned an empty response.", "red"))
|
155 |
+
return None
|
156 |
+
|
157 |
+
|
158 |
+
def get_search_terms(video_subject: str, amount: int, script: str, ai_model: str) -> List[str]:
|
159 |
+
"""
|
160 |
+
Generate a JSON-Array of search terms for stock videos,
|
161 |
+
depending on the subject of a video.
|
162 |
+
|
163 |
+
Args:
|
164 |
+
video_subject (str): The subject of the video.
|
165 |
+
amount (int): The amount of search terms to generate.
|
166 |
+
script (str): The script of the video.
|
167 |
+
ai_model (str): The AI model to use for generation.
|
168 |
+
|
169 |
+
Returns:
|
170 |
+
List[str]: The search terms for the video subject.
|
171 |
+
"""
|
172 |
+
|
173 |
+
# Build prompt
|
174 |
+
prompt = f"""
|
175 |
+
Generate {amount} search terms for stock videos,
|
176 |
+
depending on the subject of a video.
|
177 |
+
Subject: {video_subject}
|
178 |
+
|
179 |
+
The search terms are to be returned as
|
180 |
+
a JSON-Array of strings.
|
181 |
+
|
182 |
+
Each search term should consist of 1-3 words,
|
183 |
+
always add the main subject of the video.
|
184 |
+
|
185 |
+
YOU MUST ONLY RETURN THE JSON-ARRAY OF STRINGS.
|
186 |
+
YOU MUST NOT RETURN ANYTHING ELSE.
|
187 |
+
YOU MUST NOT RETURN THE SCRIPT.
|
188 |
+
|
189 |
+
The search terms must be related to the subject of the video.
|
190 |
+
Here is an example of a JSON-Array of strings:
|
191 |
+
["search term 1", "search term 2", "search term 3"]
|
192 |
+
|
193 |
+
For context, here is the full text:
|
194 |
+
{script}
|
195 |
+
"""
|
196 |
+
|
197 |
+
# Generate search terms
|
198 |
+
response = generate_response(prompt, ai_model)
|
199 |
+
|
200 |
+
# Parse response into a list of search terms
|
201 |
+
search_terms = []
|
202 |
+
|
203 |
+
try:
|
204 |
+
search_terms = json.loads(response)
|
205 |
+
if not isinstance(search_terms, list) or not all(isinstance(term, str) for term in search_terms):
|
206 |
+
raise ValueError("Response is not a list of strings.")
|
207 |
+
|
208 |
+
except (json.JSONDecodeError, ValueError):
|
209 |
+
print(colored("[*] GPT returned an unformatted response. Attempting to clean...", "yellow"))
|
210 |
+
|
211 |
+
# Attempt to extract list-like string and convert to list
|
212 |
+
match = re.search(r'\["(?:[^"\\]|\\.)*"(?:,\s*"[^"\\]*")*\]', response)
|
213 |
+
if match:
|
214 |
+
try:
|
215 |
+
search_terms = json.loads(match.group())
|
216 |
+
except json.JSONDecodeError:
|
217 |
+
print(colored("[-] Could not parse response.", "red"))
|
218 |
+
return []
|
219 |
+
|
220 |
+
|
221 |
+
|
222 |
+
# Let user know
|
223 |
+
print(colored(f"\nGenerated {len(search_terms)} search terms: {', '.join(search_terms)}", "cyan"))
|
224 |
+
|
225 |
+
# Return search terms
|
226 |
+
return search_terms
|
227 |
+
|
228 |
+
|
229 |
+
def generate_metadata(video_subject: str, script: str, ai_model: str) -> Tuple[str, str, List[str]]:
|
230 |
+
"""
|
231 |
+
Generate metadata for a YouTube video, including the title, description, and keywords.
|
232 |
+
|
233 |
+
Args:
|
234 |
+
video_subject (str): The subject of the video.
|
235 |
+
script (str): The script of the video.
|
236 |
+
ai_model (str): The AI model to use for generation.
|
237 |
+
|
238 |
+
Returns:
|
239 |
+
Tuple[str, str, List[str]]: The title, description, and keywords for the video.
|
240 |
+
"""
|
241 |
+
|
242 |
+
# Build prompt for title
|
243 |
+
title_prompt = f"""
|
244 |
+
Generate a catchy and SEO-friendly title for a YouTube shorts video about {video_subject}.
|
245 |
+
"""
|
246 |
+
|
247 |
+
# Generate title
|
248 |
+
title = generate_response(title_prompt, ai_model).strip()
|
249 |
+
|
250 |
+
# Build prompt for description
|
251 |
+
description_prompt = f"""
|
252 |
+
Write a brief and engaging description for a YouTube shorts video about {video_subject}.
|
253 |
+
The video is based on the following script:
|
254 |
+
{script}
|
255 |
+
"""
|
256 |
+
|
257 |
+
# Generate description
|
258 |
+
description = generate_response(description_prompt, ai_model).strip()
|
259 |
+
|
260 |
+
# Generate keywords
|
261 |
+
keywords = get_search_terms(video_subject, 6, script, ai_model)
|
262 |
+
|
263 |
+
return title, description, keywords
|
Backend/main.py
ADDED
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from utils import *
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
# Load environment variables
|
6 |
+
load_dotenv("../.env")
|
7 |
+
# Check if all required environment variables are set
|
8 |
+
# This must happen before importing video which uses API keys without checking
|
9 |
+
check_env_vars()
|
10 |
+
|
11 |
+
from gpt import *
|
12 |
+
from video import *
|
13 |
+
from search import *
|
14 |
+
from uuid import uuid4
|
15 |
+
from tiktokvoice import *
|
16 |
+
from flask_cors import CORS
|
17 |
+
from termcolor import colored
|
18 |
+
from youtube import upload_video
|
19 |
+
from apiclient.errors import HttpError
|
20 |
+
from flask import Flask, request, jsonify
|
21 |
+
from moviepy.config import change_settings
|
22 |
+
|
23 |
+
|
24 |
+
|
25 |
+
# Set environment variables
|
26 |
+
SESSION_ID = os.getenv("TIKTOK_SESSION_ID")
|
27 |
+
openai_api_key = os.getenv('OPENAI_API_KEY')
|
28 |
+
change_settings({"IMAGEMAGICK_BINARY": os.getenv("IMAGEMAGICK_BINARY")})
|
29 |
+
|
30 |
+
# Initialize Flask
|
31 |
+
app = Flask(__name__)
|
32 |
+
CORS(app)
|
33 |
+
|
34 |
+
# Constants
|
35 |
+
HOST = "0.0.0.0"
|
36 |
+
PORT = 8080
|
37 |
+
AMOUNT_OF_STOCK_VIDEOS = 5
|
38 |
+
GENERATING = False
|
39 |
+
|
40 |
+
|
41 |
+
# Generation Endpoint
|
42 |
+
@app.route("/api/generate", methods=["POST"])
|
43 |
+
def generate():
|
44 |
+
try:
|
45 |
+
# Set global variable
|
46 |
+
global GENERATING
|
47 |
+
GENERATING = True
|
48 |
+
|
49 |
+
# Clean
|
50 |
+
clean_dir("../temp/")
|
51 |
+
clean_dir("../subtitles/")
|
52 |
+
|
53 |
+
|
54 |
+
# Parse JSON
|
55 |
+
data = request.get_json()
|
56 |
+
paragraph_number = int(data.get('paragraphNumber', 1)) # Default to 1 if not provided
|
57 |
+
ai_model = data.get('aiModel') # Get the AI model selected by the user
|
58 |
+
n_threads = data.get('threads') # Amount of threads to use for video generation
|
59 |
+
subtitles_position = data.get('subtitlesPosition') # Position of the subtitles in the video
|
60 |
+
text_color = data.get('color') # Color of subtitle text
|
61 |
+
|
62 |
+
# Get 'useMusic' from the request data and default to False if not provided
|
63 |
+
use_music = data.get('useMusic', False)
|
64 |
+
|
65 |
+
# Get 'automateYoutubeUpload' from the request data and default to False if not provided
|
66 |
+
automate_youtube_upload = data.get('automateYoutubeUpload', False)
|
67 |
+
|
68 |
+
# Get the ZIP Url of the songs
|
69 |
+
songs_zip_url = data.get('zipUrl')
|
70 |
+
|
71 |
+
# Download songs
|
72 |
+
if use_music:
|
73 |
+
# Downloads a ZIP file containing popular TikTok Songs
|
74 |
+
if songs_zip_url:
|
75 |
+
fetch_songs(songs_zip_url)
|
76 |
+
else:
|
77 |
+
# Default to a ZIP file containing popular TikTok Songs
|
78 |
+
fetch_songs("https://filebin.net/2avx134kdibc4c3q/drive-download-20240209T180019Z-001.zip")
|
79 |
+
|
80 |
+
# Print little information about the video which is to be generated
|
81 |
+
print(colored("[Video to be generated]", "blue"))
|
82 |
+
print(colored(" Subject: " + data["videoSubject"], "blue"))
|
83 |
+
print(colored(" AI Model: " + ai_model, "blue")) # Print the AI model being used
|
84 |
+
print(colored(" Custom Prompt: " + data["customPrompt"], "blue")) # Print the AI model being used
|
85 |
+
|
86 |
+
|
87 |
+
|
88 |
+
if not GENERATING:
|
89 |
+
return jsonify(
|
90 |
+
{
|
91 |
+
"status": "error",
|
92 |
+
"message": "Video generation was cancelled.",
|
93 |
+
"data": [],
|
94 |
+
}
|
95 |
+
)
|
96 |
+
|
97 |
+
voice = data["voice"]
|
98 |
+
voice_prefix = voice[:2]
|
99 |
+
|
100 |
+
|
101 |
+
if not voice:
|
102 |
+
print(colored("[!] No voice was selected. Defaulting to \"en_us_001\"", "yellow"))
|
103 |
+
voice = "en_us_001"
|
104 |
+
voice_prefix = voice[:2]
|
105 |
+
|
106 |
+
|
107 |
+
# Generate a script
|
108 |
+
script = generate_script(data["videoSubject"], paragraph_number, ai_model, voice, data["customPrompt"]) # Pass the AI model to the script generation
|
109 |
+
|
110 |
+
# Generate search terms
|
111 |
+
search_terms = get_search_terms(
|
112 |
+
data["videoSubject"], AMOUNT_OF_STOCK_VIDEOS, script, ai_model
|
113 |
+
)
|
114 |
+
|
115 |
+
# Search for a video of the given search term
|
116 |
+
video_urls = []
|
117 |
+
|
118 |
+
# Defines how many results it should query and search through
|
119 |
+
it = 15
|
120 |
+
|
121 |
+
# Defines the minimum duration of each clip
|
122 |
+
min_dur = 10
|
123 |
+
|
124 |
+
# Loop through all search terms,
|
125 |
+
# and search for a video of the given search term
|
126 |
+
for search_term in search_terms:
|
127 |
+
if not GENERATING:
|
128 |
+
return jsonify(
|
129 |
+
{
|
130 |
+
"status": "error",
|
131 |
+
"message": "Video generation was cancelled.",
|
132 |
+
"data": [],
|
133 |
+
}
|
134 |
+
)
|
135 |
+
found_urls = search_for_stock_videos(
|
136 |
+
search_term, os.getenv("PEXELS_API_KEY"), it, min_dur
|
137 |
+
)
|
138 |
+
# Check for duplicates
|
139 |
+
for url in found_urls:
|
140 |
+
if url not in video_urls:
|
141 |
+
video_urls.append(url)
|
142 |
+
break
|
143 |
+
|
144 |
+
# Check if video_urls is empty
|
145 |
+
if not video_urls:
|
146 |
+
print(colored("[-] No videos found to download.", "red"))
|
147 |
+
return jsonify(
|
148 |
+
{
|
149 |
+
"status": "error",
|
150 |
+
"message": "No videos found to download.",
|
151 |
+
"data": [],
|
152 |
+
}
|
153 |
+
)
|
154 |
+
|
155 |
+
# Define video_paths
|
156 |
+
video_paths = []
|
157 |
+
|
158 |
+
# Let user know
|
159 |
+
print(colored(f"[+] Downloading {len(video_urls)} videos...", "blue"))
|
160 |
+
|
161 |
+
# Save the videos
|
162 |
+
for video_url in video_urls:
|
163 |
+
if not GENERATING:
|
164 |
+
return jsonify(
|
165 |
+
{
|
166 |
+
"status": "error",
|
167 |
+
"message": "Video generation was cancelled.",
|
168 |
+
"data": [],
|
169 |
+
}
|
170 |
+
)
|
171 |
+
try:
|
172 |
+
saved_video_path = save_video(video_url)
|
173 |
+
video_paths.append(saved_video_path)
|
174 |
+
except Exception:
|
175 |
+
print(colored(f"[-] Could not download video: {video_url}", "red"))
|
176 |
+
|
177 |
+
# Let user know
|
178 |
+
print(colored("[+] Videos downloaded!", "green"))
|
179 |
+
|
180 |
+
# Let user know
|
181 |
+
print(colored("[+] Script generated!\n", "green"))
|
182 |
+
|
183 |
+
if not GENERATING:
|
184 |
+
return jsonify(
|
185 |
+
{
|
186 |
+
"status": "error",
|
187 |
+
"message": "Video generation was cancelled.",
|
188 |
+
"data": [],
|
189 |
+
}
|
190 |
+
)
|
191 |
+
|
192 |
+
# Split script into sentences
|
193 |
+
sentences = script.split(". ")
|
194 |
+
|
195 |
+
# Remove empty strings
|
196 |
+
sentences = list(filter(lambda x: x != "", sentences))
|
197 |
+
paths = []
|
198 |
+
|
199 |
+
# Generate TTS for every sentence
|
200 |
+
for sentence in sentences:
|
201 |
+
if not GENERATING:
|
202 |
+
return jsonify(
|
203 |
+
{
|
204 |
+
"status": "error",
|
205 |
+
"message": "Video generation was cancelled.",
|
206 |
+
"data": [],
|
207 |
+
}
|
208 |
+
)
|
209 |
+
current_tts_path = f"../temp/{uuid4()}.mp3"
|
210 |
+
tts(sentence, voice, filename=current_tts_path)
|
211 |
+
audio_clip = AudioFileClip(current_tts_path)
|
212 |
+
paths.append(audio_clip)
|
213 |
+
|
214 |
+
# Combine all TTS files using moviepy
|
215 |
+
final_audio = concatenate_audioclips(paths)
|
216 |
+
tts_path = f"../temp/{uuid4()}.mp3"
|
217 |
+
final_audio.write_audiofile(tts_path)
|
218 |
+
|
219 |
+
try:
|
220 |
+
subtitles_path = generate_subtitles(audio_path=tts_path, sentences=sentences, audio_clips=paths, voice=voice_prefix)
|
221 |
+
except Exception as e:
|
222 |
+
print(colored(f"[-] Error generating subtitles: {e}", "red"))
|
223 |
+
subtitles_path = None
|
224 |
+
|
225 |
+
# Concatenate videos
|
226 |
+
temp_audio = AudioFileClip(tts_path)
|
227 |
+
combined_video_path = combine_videos(video_paths, temp_audio.duration, 5, n_threads or 2)
|
228 |
+
|
229 |
+
# Put everything together
|
230 |
+
try:
|
231 |
+
final_video_path = generate_video(combined_video_path, tts_path, subtitles_path, n_threads or 2, subtitles_position, text_color or "#FFFF00")
|
232 |
+
except Exception as e:
|
233 |
+
print(colored(f"[-] Error generating final video: {e}", "red"))
|
234 |
+
final_video_path = None
|
235 |
+
|
236 |
+
# Define metadata for the video, we will display this to the user, and use it for the YouTube upload
|
237 |
+
title, description, keywords = generate_metadata(data["videoSubject"], script, ai_model)
|
238 |
+
|
239 |
+
print(colored("[-] Metadata for YouTube upload:", "blue"))
|
240 |
+
print(colored(" Title: ", "blue"))
|
241 |
+
print(colored(f" {title}", "blue"))
|
242 |
+
print(colored(" Description: ", "blue"))
|
243 |
+
print(colored(f" {description}", "blue"))
|
244 |
+
print(colored(" Keywords: ", "blue"))
|
245 |
+
print(colored(f" {', '.join(keywords)}", "blue"))
|
246 |
+
|
247 |
+
if automate_youtube_upload:
|
248 |
+
# Start Youtube Uploader
|
249 |
+
# Check if the CLIENT_SECRETS_FILE exists
|
250 |
+
client_secrets_file = os.path.abspath("./client_secret.json")
|
251 |
+
SKIP_YT_UPLOAD = False
|
252 |
+
if not os.path.exists(client_secrets_file):
|
253 |
+
SKIP_YT_UPLOAD = True
|
254 |
+
print(colored("[-] Client secrets file missing. YouTube upload will be skipped.", "yellow"))
|
255 |
+
print(colored("[-] Please download the client_secret.json from Google Cloud Platform and store this inside the /Backend directory.", "red"))
|
256 |
+
|
257 |
+
# Only proceed with YouTube upload if the toggle is True and client_secret.json exists.
|
258 |
+
if not SKIP_YT_UPLOAD:
|
259 |
+
# Choose the appropriate category ID for your videos
|
260 |
+
video_category_id = "28" # Science & Technology
|
261 |
+
privacyStatus = "private" # "public", "private", "unlisted"
|
262 |
+
video_metadata = {
|
263 |
+
'video_path': os.path.abspath(f"../temp/{final_video_path}"),
|
264 |
+
'title': title,
|
265 |
+
'description': description,
|
266 |
+
'category': video_category_id,
|
267 |
+
'keywords': ",".join(keywords),
|
268 |
+
'privacyStatus': privacyStatus,
|
269 |
+
}
|
270 |
+
|
271 |
+
# Upload the video to YouTube
|
272 |
+
try:
|
273 |
+
# Unpack the video_metadata dictionary into individual arguments
|
274 |
+
video_response = upload_video(
|
275 |
+
video_path=video_metadata['video_path'],
|
276 |
+
title=video_metadata['title'],
|
277 |
+
description=video_metadata['description'],
|
278 |
+
category=video_metadata['category'],
|
279 |
+
keywords=video_metadata['keywords'],
|
280 |
+
privacy_status=video_metadata['privacyStatus']
|
281 |
+
)
|
282 |
+
print(f"Uploaded video ID: {video_response.get('id')}")
|
283 |
+
except HttpError as e:
|
284 |
+
print(f"An HTTP error {e.resp.status} occurred:\n{e.content}")
|
285 |
+
|
286 |
+
video_clip = VideoFileClip(f"../temp/{final_video_path}")
|
287 |
+
if use_music:
|
288 |
+
# Select a random song
|
289 |
+
song_path = choose_random_song()
|
290 |
+
|
291 |
+
# Add song to video at 30% volume using moviepy
|
292 |
+
original_duration = video_clip.duration
|
293 |
+
original_audio = video_clip.audio
|
294 |
+
song_clip = AudioFileClip(song_path).set_fps(44100)
|
295 |
+
|
296 |
+
# Set the volume of the song to 10% of the original volume
|
297 |
+
song_clip = song_clip.volumex(0.1).set_fps(44100)
|
298 |
+
|
299 |
+
# Add the song to the video
|
300 |
+
comp_audio = CompositeAudioClip([original_audio, song_clip])
|
301 |
+
video_clip = video_clip.set_audio(comp_audio)
|
302 |
+
video_clip = video_clip.set_fps(30)
|
303 |
+
video_clip = video_clip.set_duration(original_duration)
|
304 |
+
video_clip.write_videofile(f"../{final_video_path}", threads=n_threads or 1)
|
305 |
+
else:
|
306 |
+
video_clip.write_videofile(f"../{final_video_path}", threads=n_threads or 1)
|
307 |
+
|
308 |
+
|
309 |
+
# Let user know
|
310 |
+
print(colored(f"[+] Video generated: {final_video_path}!", "green"))
|
311 |
+
|
312 |
+
# Stop FFMPEG processes
|
313 |
+
if os.name == "nt":
|
314 |
+
# Windows
|
315 |
+
os.system("taskkill /f /im ffmpeg.exe")
|
316 |
+
else:
|
317 |
+
# Other OS
|
318 |
+
os.system("pkill -f ffmpeg")
|
319 |
+
|
320 |
+
GENERATING = False
|
321 |
+
|
322 |
+
# Return JSON
|
323 |
+
return jsonify(
|
324 |
+
{
|
325 |
+
"status": "success",
|
326 |
+
"message": "Video generated! See MoneyPrinter/output.mp4 for result.",
|
327 |
+
"data": final_video_path,
|
328 |
+
}
|
329 |
+
)
|
330 |
+
except Exception as err:
|
331 |
+
print(colored(f"[-] Error: {str(err)}", "red"))
|
332 |
+
return jsonify(
|
333 |
+
{
|
334 |
+
"status": "error",
|
335 |
+
"message": f"Could not retrieve stock videos: {str(err)}",
|
336 |
+
"data": [],
|
337 |
+
}
|
338 |
+
)
|
339 |
+
|
340 |
+
|
341 |
+
@app.route("/api/cancel", methods=["POST"])
|
342 |
+
def cancel():
|
343 |
+
print(colored("[!] Received cancellation request...", "yellow"))
|
344 |
+
|
345 |
+
global GENERATING
|
346 |
+
GENERATING = False
|
347 |
+
|
348 |
+
return jsonify({"status": "success", "message": "Cancelled video generation."})
|
349 |
+
|
350 |
+
|
351 |
+
if __name__ == "__main__":
|
352 |
+
|
353 |
+
# Run Flask App
|
354 |
+
app.run(debug=True, host=HOST, port=PORT)
|
Backend/search.py
ADDED
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import requests
|
2 |
+
|
3 |
+
from typing import List
|
4 |
+
from termcolor import colored
|
5 |
+
|
6 |
+
def search_for_stock_videos(query: str, api_key: str, it: int, min_dur: int) -> List[str]:
|
7 |
+
"""
|
8 |
+
Searches for stock videos based on a query.
|
9 |
+
|
10 |
+
Args:
|
11 |
+
query (str): The query to search for.
|
12 |
+
api_key (str): The API key to use.
|
13 |
+
|
14 |
+
Returns:
|
15 |
+
List[str]: A list of stock videos.
|
16 |
+
"""
|
17 |
+
|
18 |
+
# Build headers
|
19 |
+
headers = {
|
20 |
+
"Authorization": api_key
|
21 |
+
}
|
22 |
+
|
23 |
+
# Build URL
|
24 |
+
qurl = f"https://api.pexels.com/videos/search?query={query}&per_page={it}"
|
25 |
+
|
26 |
+
# Send the request
|
27 |
+
r = requests.get(qurl, headers=headers)
|
28 |
+
|
29 |
+
# Parse the response
|
30 |
+
response = r.json()
|
31 |
+
|
32 |
+
# Parse each video
|
33 |
+
raw_urls = []
|
34 |
+
video_url = []
|
35 |
+
video_res = 0
|
36 |
+
try:
|
37 |
+
# loop through each video in the result
|
38 |
+
for i in range(it):
|
39 |
+
#check if video has desired minimum duration
|
40 |
+
if response["videos"][i]["duration"] < min_dur:
|
41 |
+
continue
|
42 |
+
raw_urls = response["videos"][i]["video_files"]
|
43 |
+
temp_video_url = ""
|
44 |
+
|
45 |
+
# loop through each url to determine the best quality
|
46 |
+
for video in raw_urls:
|
47 |
+
# Check if video has a valid download link
|
48 |
+
if ".com/external" in video["link"]:
|
49 |
+
# Only save the URL with the largest resolution
|
50 |
+
if (video["width"]*video["height"]) > video_res:
|
51 |
+
temp_video_url = video["link"]
|
52 |
+
video_res = video["width"]*video["height"]
|
53 |
+
|
54 |
+
# add the url to the return list if it's not empty
|
55 |
+
if temp_video_url != "":
|
56 |
+
video_url.append(temp_video_url)
|
57 |
+
|
58 |
+
except Exception as e:
|
59 |
+
print(colored("[-] No Videos found.", "red"))
|
60 |
+
print(colored(e, "red"))
|
61 |
+
|
62 |
+
# Let user know
|
63 |
+
print(colored(f"\t=> \"{query}\" found {len(video_url)} Videos", "cyan"))
|
64 |
+
|
65 |
+
# Return the video url
|
66 |
+
return video_url
|
Backend/tiktokvoice.py
ADDED
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# author: GiorDior aka Giorgio
|
2 |
+
# date: 12.06.2023
|
3 |
+
# topic: TikTok-Voice-TTS
|
4 |
+
# version: 1.0
|
5 |
+
# credits: https://github.com/oscie57/tiktok-voice
|
6 |
+
|
7 |
+
# --- MODIFIED VERSION --- #
|
8 |
+
|
9 |
+
import base64
|
10 |
+
import requests
|
11 |
+
import threading
|
12 |
+
|
13 |
+
from typing import List
|
14 |
+
from termcolor import colored
|
15 |
+
from playsound import playsound
|
16 |
+
|
17 |
+
|
18 |
+
VOICES = [
|
19 |
+
# DISNEY VOICES
|
20 |
+
"en_us_ghostface", # Ghost Face
|
21 |
+
"en_us_chewbacca", # Chewbacca
|
22 |
+
"en_us_c3po", # C3PO
|
23 |
+
"en_us_stitch", # Stitch
|
24 |
+
"en_us_stormtrooper", # Stormtrooper
|
25 |
+
"en_us_rocket", # Rocket
|
26 |
+
# ENGLISH VOICES
|
27 |
+
"en_au_001", # English AU - Female
|
28 |
+
"en_au_002", # English AU - Male
|
29 |
+
"en_uk_001", # English UK - Male 1
|
30 |
+
"en_uk_003", # English UK - Male 2
|
31 |
+
"en_us_001", # English US - Female (Int. 1)
|
32 |
+
"en_us_002", # English US - Female (Int. 2)
|
33 |
+
"en_us_006", # English US - Male 1
|
34 |
+
"en_us_007", # English US - Male 2
|
35 |
+
"en_us_009", # English US - Male 3
|
36 |
+
"en_us_010", # English US - Male 4
|
37 |
+
# EUROPE VOICES
|
38 |
+
"fr_001", # French - Male 1
|
39 |
+
"fr_002", # French - Male 2
|
40 |
+
"de_001", # German - Female
|
41 |
+
"de_002", # German - Male
|
42 |
+
"es_002", # Spanish - Male
|
43 |
+
# AMERICA VOICES
|
44 |
+
"es_mx_002", # Spanish MX - Male
|
45 |
+
"br_001", # Portuguese BR - Female 1
|
46 |
+
"br_003", # Portuguese BR - Female 2
|
47 |
+
"br_004", # Portuguese BR - Female 3
|
48 |
+
"br_005", # Portuguese BR - Male
|
49 |
+
# ASIA VOICES
|
50 |
+
"id_001", # Indonesian - Female
|
51 |
+
"jp_001", # Japanese - Female 1
|
52 |
+
"jp_003", # Japanese - Female 2
|
53 |
+
"jp_005", # Japanese - Female 3
|
54 |
+
"jp_006", # Japanese - Male
|
55 |
+
"kr_002", # Korean - Male 1
|
56 |
+
"kr_003", # Korean - Female
|
57 |
+
"kr_004", # Korean - Male 2
|
58 |
+
# SINGING VOICES
|
59 |
+
"en_female_f08_salut_damour", # Alto
|
60 |
+
"en_male_m03_lobby", # Tenor
|
61 |
+
"en_female_f08_warmy_breeze", # Warmy Breeze
|
62 |
+
"en_male_m03_sunshine_soon", # Sunshine Soon
|
63 |
+
# OTHER
|
64 |
+
"en_male_narration", # narrator
|
65 |
+
"en_male_funny", # wacky
|
66 |
+
"en_female_emotional", # peaceful
|
67 |
+
]
|
68 |
+
|
69 |
+
ENDPOINTS = [
|
70 |
+
"https://tiktok-tts.weilnet.workers.dev/api/generation",
|
71 |
+
"https://tiktoktts.com/api/tiktok-tts",
|
72 |
+
]
|
73 |
+
current_endpoint = 0
|
74 |
+
# in one conversion, the text can have a maximum length of 300 characters
|
75 |
+
TEXT_BYTE_LIMIT = 300
|
76 |
+
|
77 |
+
|
78 |
+
# create a list by splitting a string, every element has n chars
|
79 |
+
def split_string(string: str, chunk_size: int) -> List[str]:
|
80 |
+
words = string.split()
|
81 |
+
result = []
|
82 |
+
current_chunk = ""
|
83 |
+
for word in words:
|
84 |
+
if (
|
85 |
+
len(current_chunk) + len(word) + 1 <= chunk_size
|
86 |
+
): # Check if adding the word exceeds the chunk size
|
87 |
+
current_chunk += f" {word}"
|
88 |
+
else:
|
89 |
+
if current_chunk: # Append the current chunk if not empty
|
90 |
+
result.append(current_chunk.strip())
|
91 |
+
current_chunk = word
|
92 |
+
if current_chunk: # Append the last chunk if not empty
|
93 |
+
result.append(current_chunk.strip())
|
94 |
+
return result
|
95 |
+
|
96 |
+
|
97 |
+
# checking if the website that provides the service is available
|
98 |
+
def get_api_response() -> requests.Response:
|
99 |
+
url = f'{ENDPOINTS[current_endpoint].split("/a")[0]}'
|
100 |
+
response = requests.get(url)
|
101 |
+
return response
|
102 |
+
|
103 |
+
|
104 |
+
# saving the audio file
|
105 |
+
def save_audio_file(base64_data: str, filename: str = "output.mp3") -> None:
|
106 |
+
audio_bytes = base64.b64decode(base64_data)
|
107 |
+
with open(filename, "wb") as file:
|
108 |
+
file.write(audio_bytes)
|
109 |
+
|
110 |
+
|
111 |
+
# send POST request to get the audio data
|
112 |
+
def generate_audio(text: str, voice: str) -> bytes:
|
113 |
+
url = f"{ENDPOINTS[current_endpoint]}"
|
114 |
+
headers = {"Content-Type": "application/json"}
|
115 |
+
data = {"text": text, "voice": voice}
|
116 |
+
response = requests.post(url, headers=headers, json=data)
|
117 |
+
return response.content
|
118 |
+
|
119 |
+
|
120 |
+
# creates an text to speech audio file
|
121 |
+
def tts(
|
122 |
+
text: str,
|
123 |
+
voice: str = "none",
|
124 |
+
filename: str = "output.mp3",
|
125 |
+
play_sound: bool = False,
|
126 |
+
) -> None:
|
127 |
+
# checking if the website is available
|
128 |
+
global current_endpoint
|
129 |
+
|
130 |
+
if get_api_response().status_code == 200:
|
131 |
+
print(colored("[+] TikTok TTS Service available!", "green"))
|
132 |
+
else:
|
133 |
+
current_endpoint = (current_endpoint + 1) % 2
|
134 |
+
if get_api_response().status_code == 200:
|
135 |
+
print(colored("[+] TTS Service available!", "green"))
|
136 |
+
else:
|
137 |
+
print(colored("[-] TTS Service not available and probably temporarily rate limited, try again later..." , "red"))
|
138 |
+
return
|
139 |
+
|
140 |
+
# checking if arguments are valid
|
141 |
+
if voice == "none":
|
142 |
+
print(colored("[-] Please specify a voice", "red"))
|
143 |
+
return
|
144 |
+
|
145 |
+
if voice not in VOICES:
|
146 |
+
print(colored("[-] Voice not available", "red"))
|
147 |
+
return
|
148 |
+
|
149 |
+
if not text:
|
150 |
+
print(colored("[-] Please specify a text", "red"))
|
151 |
+
return
|
152 |
+
|
153 |
+
# creating the audio file
|
154 |
+
try:
|
155 |
+
if len(text) < TEXT_BYTE_LIMIT:
|
156 |
+
audio = generate_audio((text), voice)
|
157 |
+
if current_endpoint == 0:
|
158 |
+
audio_base64_data = str(audio).split('"')[5]
|
159 |
+
else:
|
160 |
+
audio_base64_data = str(audio).split('"')[3].split(",")[1]
|
161 |
+
|
162 |
+
if audio_base64_data == "error":
|
163 |
+
print(colored("[-] This voice is unavailable right now", "red"))
|
164 |
+
return
|
165 |
+
|
166 |
+
else:
|
167 |
+
# Split longer text into smaller parts
|
168 |
+
text_parts = split_string(text, 299)
|
169 |
+
audio_base64_data = [None] * len(text_parts)
|
170 |
+
|
171 |
+
# Define a thread function to generate audio for each text part
|
172 |
+
def generate_audio_thread(text_part, index):
|
173 |
+
audio = generate_audio(text_part, voice)
|
174 |
+
if current_endpoint == 0:
|
175 |
+
base64_data = str(audio).split('"')[5]
|
176 |
+
else:
|
177 |
+
base64_data = str(audio).split('"')[3].split(",")[1]
|
178 |
+
|
179 |
+
if audio_base64_data == "error":
|
180 |
+
print(colored("[-] This voice is unavailable right now", "red"))
|
181 |
+
return "error"
|
182 |
+
|
183 |
+
audio_base64_data[index] = base64_data
|
184 |
+
|
185 |
+
threads = []
|
186 |
+
for index, text_part in enumerate(text_parts):
|
187 |
+
# Create and start a new thread for each text part
|
188 |
+
thread = threading.Thread(
|
189 |
+
target=generate_audio_thread, args=(text_part, index)
|
190 |
+
)
|
191 |
+
thread.start()
|
192 |
+
threads.append(thread)
|
193 |
+
|
194 |
+
# Wait for all threads to complete
|
195 |
+
for thread in threads:
|
196 |
+
thread.join()
|
197 |
+
|
198 |
+
# Concatenate the base64 data in the correct order
|
199 |
+
audio_base64_data = "".join(audio_base64_data)
|
200 |
+
|
201 |
+
save_audio_file(audio_base64_data, filename)
|
202 |
+
print(colored(f"[+] Audio file saved successfully as '{filename}'", "green"))
|
203 |
+
if play_sound:
|
204 |
+
playsound(filename)
|
205 |
+
|
206 |
+
except Exception as e:
|
207 |
+
print(colored(f"[-] An error occurred during TTS: {e}", "red"))
|
Backend/utils.py
ADDED
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
import json
|
4 |
+
import random
|
5 |
+
import logging
|
6 |
+
import zipfile
|
7 |
+
import requests
|
8 |
+
|
9 |
+
from termcolor import colored
|
10 |
+
|
11 |
+
# Configure logging
|
12 |
+
logging.basicConfig(level=logging.INFO)
|
13 |
+
logger = logging.getLogger(__name__)
|
14 |
+
|
15 |
+
|
16 |
+
def clean_dir(path: str) -> None:
|
17 |
+
"""
|
18 |
+
Removes every file in a directory.
|
19 |
+
|
20 |
+
Args:
|
21 |
+
path (str): Path to directory.
|
22 |
+
|
23 |
+
Returns:
|
24 |
+
None
|
25 |
+
"""
|
26 |
+
try:
|
27 |
+
if not os.path.exists(path):
|
28 |
+
os.mkdir(path)
|
29 |
+
logger.info(f"Created directory: {path}")
|
30 |
+
|
31 |
+
for file in os.listdir(path):
|
32 |
+
file_path = os.path.join(path, file)
|
33 |
+
os.remove(file_path)
|
34 |
+
logger.info(f"Removed file: {file_path}")
|
35 |
+
|
36 |
+
logger.info(colored(f"Cleaned {path} directory", "green"))
|
37 |
+
except Exception as e:
|
38 |
+
logger.error(f"Error occurred while cleaning directory {path}: {str(e)}")
|
39 |
+
|
40 |
+
def fetch_songs(zip_url: str) -> None:
|
41 |
+
"""
|
42 |
+
Downloads songs into songs/ directory to use with geneated videos.
|
43 |
+
|
44 |
+
Args:
|
45 |
+
zip_url (str): The URL to the zip file containing the songs.
|
46 |
+
|
47 |
+
Returns:
|
48 |
+
None
|
49 |
+
"""
|
50 |
+
try:
|
51 |
+
logger.info(colored(f" => Fetching songs...", "magenta"))
|
52 |
+
|
53 |
+
files_dir = "../Songs"
|
54 |
+
if not os.path.exists(files_dir):
|
55 |
+
os.mkdir(files_dir)
|
56 |
+
logger.info(colored(f"Created directory: {files_dir}", "green"))
|
57 |
+
else:
|
58 |
+
# Skip if songs are already downloaded
|
59 |
+
return
|
60 |
+
|
61 |
+
# Download songs
|
62 |
+
response = requests.get(zip_url)
|
63 |
+
|
64 |
+
# Save the zip file
|
65 |
+
with open("../Songs/songs.zip", "wb") as file:
|
66 |
+
file.write(response.content)
|
67 |
+
|
68 |
+
# Unzip the file
|
69 |
+
with zipfile.ZipFile("../Songs/songs.zip", "r") as file:
|
70 |
+
file.extractall("../Songs")
|
71 |
+
|
72 |
+
# Remove the zip file
|
73 |
+
os.remove("../Songs/songs.zip")
|
74 |
+
|
75 |
+
logger.info(colored(" => Downloaded Songs to ../Songs.", "green"))
|
76 |
+
|
77 |
+
except Exception as e:
|
78 |
+
logger.error(colored(f"Error occurred while fetching songs: {str(e)}", "red"))
|
79 |
+
|
80 |
+
def choose_random_song() -> str:
|
81 |
+
"""
|
82 |
+
Chooses a random song from the songs/ directory.
|
83 |
+
|
84 |
+
Returns:
|
85 |
+
str: The path to the chosen song.
|
86 |
+
"""
|
87 |
+
try:
|
88 |
+
songs = os.listdir("../Songs")
|
89 |
+
song = random.choice(songs)
|
90 |
+
logger.info(colored(f"Chose song: {song}", "green"))
|
91 |
+
return f"../Songs/{song}"
|
92 |
+
except Exception as e:
|
93 |
+
logger.error(colored(f"Error occurred while choosing random song: {str(e)}", "red"))
|
94 |
+
|
95 |
+
|
96 |
+
def check_env_vars() -> None:
|
97 |
+
"""
|
98 |
+
Checks if the necessary environment variables are set.
|
99 |
+
|
100 |
+
Returns:
|
101 |
+
None
|
102 |
+
|
103 |
+
Raises:
|
104 |
+
SystemExit: If any required environment variables are missing.
|
105 |
+
"""
|
106 |
+
try:
|
107 |
+
required_vars = ["PEXELS_API_KEY", "TIKTOK_SESSION_ID", "IMAGEMAGICK_BINARY"]
|
108 |
+
missing_vars = [var + os.getenv(var) for var in required_vars if os.getenv(var) is None or (len(os.getenv(var)) == 0)]
|
109 |
+
|
110 |
+
if missing_vars:
|
111 |
+
missing_vars_str = ", ".join(missing_vars)
|
112 |
+
logger.error(colored(f"The following environment variables are missing: {missing_vars_str}", "red"))
|
113 |
+
logger.error(colored("Please consult 'EnvironmentVariables.md' for instructions on how to set them.", "yellow"))
|
114 |
+
sys.exit(1) # Aborts the program
|
115 |
+
except Exception as e:
|
116 |
+
logger.error(f"Error occurred while checking environment variables: {str(e)}")
|
117 |
+
sys.exit(1) # Aborts the program if an unexpected error occurs
|
118 |
+
|
Backend/video.py
ADDED
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import uuid
|
3 |
+
|
4 |
+
import requests
|
5 |
+
import srt_equalizer
|
6 |
+
import assemblyai as aai
|
7 |
+
|
8 |
+
from typing import List
|
9 |
+
from moviepy.editor import *
|
10 |
+
from termcolor import colored
|
11 |
+
from dotenv import load_dotenv
|
12 |
+
from datetime import timedelta
|
13 |
+
from moviepy.video.fx.all import crop
|
14 |
+
from moviepy.video.tools.subtitles import SubtitlesClip
|
15 |
+
|
16 |
+
load_dotenv("../.env")
|
17 |
+
|
18 |
+
ASSEMBLY_AI_API_KEY = os.getenv("ASSEMBLY_AI_API_KEY")
|
19 |
+
|
20 |
+
|
21 |
+
def save_video(video_url: str, directory: str = "../temp") -> str:
|
22 |
+
"""
|
23 |
+
Saves a video from a given URL and returns the path to the video.
|
24 |
+
|
25 |
+
Args:
|
26 |
+
video_url (str): The URL of the video to save.
|
27 |
+
directory (str): The path of the temporary directory to save the video to
|
28 |
+
|
29 |
+
Returns:
|
30 |
+
str: The path to the saved video.
|
31 |
+
"""
|
32 |
+
video_id = uuid.uuid4()
|
33 |
+
video_path = f"{directory}/{video_id}.mp4"
|
34 |
+
with open(video_path, "wb") as f:
|
35 |
+
f.write(requests.get(video_url).content)
|
36 |
+
|
37 |
+
return video_path
|
38 |
+
|
39 |
+
|
40 |
+
def __generate_subtitles_assemblyai(audio_path: str, voice: str) -> str:
|
41 |
+
"""
|
42 |
+
Generates subtitles from a given audio file and returns the path to the subtitles.
|
43 |
+
|
44 |
+
Args:
|
45 |
+
audio_path (str): The path to the audio file to generate subtitles from.
|
46 |
+
|
47 |
+
Returns:
|
48 |
+
str: The generated subtitles
|
49 |
+
"""
|
50 |
+
|
51 |
+
language_mapping = {
|
52 |
+
"br": "pt",
|
53 |
+
"id": "en", #AssemblyAI doesn't have Indonesian
|
54 |
+
"jp": "ja",
|
55 |
+
"kr": "ko",
|
56 |
+
}
|
57 |
+
|
58 |
+
if voice in language_mapping:
|
59 |
+
lang_code = language_mapping[voice]
|
60 |
+
else:
|
61 |
+
lang_code = voice
|
62 |
+
|
63 |
+
aai.settings.api_key = ASSEMBLY_AI_API_KEY
|
64 |
+
config = aai.TranscriptionConfig(language_code=lang_code)
|
65 |
+
transcriber = aai.Transcriber(config=config)
|
66 |
+
transcript = transcriber.transcribe(audio_path)
|
67 |
+
subtitles = transcript.export_subtitles_srt()
|
68 |
+
|
69 |
+
return subtitles
|
70 |
+
|
71 |
+
|
72 |
+
def __generate_subtitles_locally(sentences: List[str], audio_clips: List[AudioFileClip]) -> str:
|
73 |
+
"""
|
74 |
+
Generates subtitles from a given audio file and returns the path to the subtitles.
|
75 |
+
|
76 |
+
Args:
|
77 |
+
sentences (List[str]): all the sentences said out loud in the audio clips
|
78 |
+
audio_clips (List[AudioFileClip]): all the individual audio clips which will make up the final audio track
|
79 |
+
Returns:
|
80 |
+
str: The generated subtitles
|
81 |
+
"""
|
82 |
+
|
83 |
+
def convert_to_srt_time_format(total_seconds):
|
84 |
+
# Convert total seconds to the SRT time format: HH:MM:SS,mmm
|
85 |
+
if total_seconds == 0:
|
86 |
+
return "0:00:00,0"
|
87 |
+
return str(timedelta(seconds=total_seconds)).rstrip('0').replace('.', ',')
|
88 |
+
|
89 |
+
start_time = 0
|
90 |
+
subtitles = []
|
91 |
+
|
92 |
+
for i, (sentence, audio_clip) in enumerate(zip(sentences, audio_clips), start=1):
|
93 |
+
duration = audio_clip.duration
|
94 |
+
end_time = start_time + duration
|
95 |
+
|
96 |
+
# Format: subtitle index, start time --> end time, sentence
|
97 |
+
subtitle_entry = f"{i}\n{convert_to_srt_time_format(start_time)} --> {convert_to_srt_time_format(end_time)}\n{sentence}\n"
|
98 |
+
subtitles.append(subtitle_entry)
|
99 |
+
|
100 |
+
start_time += duration # Update start time for the next subtitle
|
101 |
+
|
102 |
+
return "\n".join(subtitles)
|
103 |
+
|
104 |
+
|
105 |
+
def generate_subtitles(audio_path: str, sentences: List[str], audio_clips: List[AudioFileClip], voice: str) -> str:
|
106 |
+
"""
|
107 |
+
Generates subtitles from a given audio file and returns the path to the subtitles.
|
108 |
+
|
109 |
+
Args:
|
110 |
+
audio_path (str): The path to the audio file to generate subtitles from.
|
111 |
+
sentences (List[str]): all the sentences said out loud in the audio clips
|
112 |
+
audio_clips (List[AudioFileClip]): all the individual audio clips which will make up the final audio track
|
113 |
+
|
114 |
+
Returns:
|
115 |
+
str: The path to the generated subtitles.
|
116 |
+
"""
|
117 |
+
|
118 |
+
def equalize_subtitles(srt_path: str, max_chars: int = 10) -> None:
|
119 |
+
# Equalize subtitles
|
120 |
+
srt_equalizer.equalize_srt_file(srt_path, srt_path, max_chars)
|
121 |
+
|
122 |
+
# Save subtitles
|
123 |
+
subtitles_path = f"../subtitles/{uuid.uuid4()}.srt"
|
124 |
+
|
125 |
+
if ASSEMBLY_AI_API_KEY is not None and ASSEMBLY_AI_API_KEY != "":
|
126 |
+
print(colored("[+] Creating subtitles using AssemblyAI", "blue"))
|
127 |
+
subtitles = __generate_subtitles_assemblyai(audio_path, voice)
|
128 |
+
else:
|
129 |
+
print(colored("[+] Creating subtitles locally", "blue"))
|
130 |
+
subtitles = __generate_subtitles_locally(sentences, audio_clips)
|
131 |
+
# print(colored("[-] Local subtitle generation has been disabled for the time being.", "red"))
|
132 |
+
# print(colored("[-] Exiting.", "red"))
|
133 |
+
# sys.exit(1)
|
134 |
+
|
135 |
+
with open(subtitles_path, "w") as file:
|
136 |
+
file.write(subtitles)
|
137 |
+
|
138 |
+
# Equalize subtitles
|
139 |
+
equalize_subtitles(subtitles_path)
|
140 |
+
|
141 |
+
print(colored("[+] Subtitles generated.", "green"))
|
142 |
+
|
143 |
+
return subtitles_path
|
144 |
+
|
145 |
+
|
146 |
+
def combine_videos(video_paths: List[str], max_duration: int, max_clip_duration: int, threads: int) -> str:
|
147 |
+
"""
|
148 |
+
Combines a list of videos into one video and returns the path to the combined video.
|
149 |
+
|
150 |
+
Args:
|
151 |
+
video_paths (List): A list of paths to the videos to combine.
|
152 |
+
max_duration (int): The maximum duration of the combined video.
|
153 |
+
max_clip_duration (int): The maximum duration of each clip.
|
154 |
+
threads (int): The number of threads to use for the video processing.
|
155 |
+
|
156 |
+
Returns:
|
157 |
+
str: The path to the combined video.
|
158 |
+
"""
|
159 |
+
video_id = uuid.uuid4()
|
160 |
+
combined_video_path = f"../temp/{video_id}.mp4"
|
161 |
+
|
162 |
+
# Required duration of each clip
|
163 |
+
req_dur = max_duration / len(video_paths)
|
164 |
+
|
165 |
+
print(colored("[+] Combining videos...", "blue"))
|
166 |
+
print(colored(f"[+] Each clip will be maximum {req_dur} seconds long.", "blue"))
|
167 |
+
|
168 |
+
clips = []
|
169 |
+
tot_dur = 0
|
170 |
+
# Add downloaded clips over and over until the duration of the audio (max_duration) has been reached
|
171 |
+
while tot_dur < max_duration:
|
172 |
+
for video_path in video_paths:
|
173 |
+
clip = VideoFileClip(video_path)
|
174 |
+
clip = clip.without_audio()
|
175 |
+
# Check if clip is longer than the remaining audio
|
176 |
+
if (max_duration - tot_dur) < clip.duration:
|
177 |
+
clip = clip.subclip(0, (max_duration - tot_dur))
|
178 |
+
# Only shorten clips if the calculated clip length (req_dur) is shorter than the actual clip to prevent still image
|
179 |
+
elif req_dur < clip.duration:
|
180 |
+
clip = clip.subclip(0, req_dur)
|
181 |
+
clip = clip.set_fps(30)
|
182 |
+
|
183 |
+
# Not all videos are same size,
|
184 |
+
# so we need to resize them
|
185 |
+
if round((clip.w/clip.h), 4) < 0.5625:
|
186 |
+
clip = crop(clip, width=clip.w, height=round(clip.w/0.5625), \
|
187 |
+
x_center=clip.w / 2, \
|
188 |
+
y_center=clip.h / 2)
|
189 |
+
else:
|
190 |
+
clip = crop(clip, width=round(0.5625*clip.h), height=clip.h, \
|
191 |
+
x_center=clip.w / 2, \
|
192 |
+
y_center=clip.h / 2)
|
193 |
+
clip = clip.resize((1080, 1920))
|
194 |
+
|
195 |
+
if clip.duration > max_clip_duration:
|
196 |
+
clip = clip.subclip(0, max_clip_duration)
|
197 |
+
|
198 |
+
clips.append(clip)
|
199 |
+
tot_dur += clip.duration
|
200 |
+
|
201 |
+
final_clip = concatenate_videoclips(clips)
|
202 |
+
final_clip = final_clip.set_fps(30)
|
203 |
+
final_clip.write_videofile(combined_video_path, threads=threads)
|
204 |
+
|
205 |
+
return combined_video_path
|
206 |
+
|
207 |
+
|
208 |
+
def generate_video(combined_video_path: str, tts_path: str, subtitles_path: str, threads: int, subtitles_position: str, text_color : str) -> str:
|
209 |
+
"""
|
210 |
+
This function creates the final video, with subtitles and audio.
|
211 |
+
|
212 |
+
Args:
|
213 |
+
combined_video_path (str): The path to the combined video.
|
214 |
+
tts_path (str): The path to the text-to-speech audio.
|
215 |
+
subtitles_path (str): The path to the subtitles.
|
216 |
+
threads (int): The number of threads to use for the video processing.
|
217 |
+
subtitles_position (str): The position of the subtitles.
|
218 |
+
|
219 |
+
Returns:
|
220 |
+
str: The path to the final video.
|
221 |
+
"""
|
222 |
+
# Make a generator that returns a TextClip when called with consecutive
|
223 |
+
generator = lambda txt: TextClip(
|
224 |
+
txt,
|
225 |
+
font="../fonts/bold_font.ttf",
|
226 |
+
fontsize=100,
|
227 |
+
color=text_color,
|
228 |
+
stroke_color="black",
|
229 |
+
stroke_width=5,
|
230 |
+
)
|
231 |
+
|
232 |
+
# Split the subtitles position into horizontal and vertical
|
233 |
+
horizontal_subtitles_position, vertical_subtitles_position = subtitles_position.split(",")
|
234 |
+
|
235 |
+
# Burn the subtitles into the video
|
236 |
+
subtitles = SubtitlesClip(subtitles_path, generator)
|
237 |
+
result = CompositeVideoClip([
|
238 |
+
VideoFileClip(combined_video_path),
|
239 |
+
subtitles.set_pos((horizontal_subtitles_position, vertical_subtitles_position))
|
240 |
+
])
|
241 |
+
|
242 |
+
# Add the audio
|
243 |
+
audio = AudioFileClip(tts_path)
|
244 |
+
result = result.set_audio(audio)
|
245 |
+
|
246 |
+
result.write_videofile("../temp/output.mp4", threads=threads or 2)
|
247 |
+
|
248 |
+
return "output.mp4"
|
Backend/youtube.py
ADDED
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
import time
|
4 |
+
import random
|
5 |
+
import httplib2
|
6 |
+
|
7 |
+
from termcolor import colored
|
8 |
+
from oauth2client.file import Storage
|
9 |
+
from apiclient.discovery import build
|
10 |
+
from apiclient.errors import HttpError
|
11 |
+
from apiclient.http import MediaFileUpload
|
12 |
+
from oauth2client.tools import argparser, run_flow
|
13 |
+
from oauth2client.client import flow_from_clientsecrets
|
14 |
+
|
15 |
+
# Explicitly tell the underlying HTTP transport library not to retry, since
|
16 |
+
# we are handling retry logic ourselves.
|
17 |
+
httplib2.RETRIES = 1
|
18 |
+
|
19 |
+
# Maximum number of times to retry before giving up.
|
20 |
+
MAX_RETRIES = 10
|
21 |
+
|
22 |
+
# Always retry when these exceptions are raised.
|
23 |
+
RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError, httplib2.ServerNotFoundError)
|
24 |
+
|
25 |
+
# Always retry when an apiclient.errors.HttpError with one of these status
|
26 |
+
# codes is raised.
|
27 |
+
RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
|
28 |
+
|
29 |
+
# The CLIENT_SECRETS_FILE variable specifies the name of a file that contains
|
30 |
+
# the OAuth 2.0 information for this application, including its client_id and
|
31 |
+
# client_secret.
|
32 |
+
CLIENT_SECRETS_FILE = "./client_secret.json"
|
33 |
+
|
34 |
+
# This OAuth 2.0 access scope allows an application to upload files to the
|
35 |
+
# authenticated user's YouTube channel, but doesn't allow other types of access.
|
36 |
+
# YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
|
37 |
+
SCOPES = ['https://www.googleapis.com/auth/youtube.upload',
|
38 |
+
'https://www.googleapis.com/auth/youtube',
|
39 |
+
'https://www.googleapis.com/auth/youtubepartner']
|
40 |
+
YOUTUBE_API_SERVICE_NAME = "youtube"
|
41 |
+
YOUTUBE_API_VERSION = "v3"
|
42 |
+
|
43 |
+
# This variable defines a message to display if the CLIENT_SECRETS_FILE is
|
44 |
+
# missing.
|
45 |
+
MISSING_CLIENT_SECRETS_MESSAGE = f"""
|
46 |
+
WARNING: Please configure OAuth 2.0
|
47 |
+
|
48 |
+
To make this sample run you will need to populate the client_secrets.json file
|
49 |
+
found at:
|
50 |
+
|
51 |
+
{os.path.abspath(os.path.join(os.path.dirname(__file__), CLIENT_SECRETS_FILE))}
|
52 |
+
|
53 |
+
with information from the API Console
|
54 |
+
https://console.cloud.google.com/
|
55 |
+
|
56 |
+
For more information about the client_secrets.json file format, please visit:
|
57 |
+
https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
|
58 |
+
"""
|
59 |
+
|
60 |
+
VALID_PRIVACY_STATUSES = ("public", "private", "unlisted")
|
61 |
+
|
62 |
+
|
63 |
+
def get_authenticated_service():
|
64 |
+
"""
|
65 |
+
This method retrieves the YouTube service.
|
66 |
+
|
67 |
+
Returns:
|
68 |
+
any: The authenticated YouTube service.
|
69 |
+
"""
|
70 |
+
flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE,
|
71 |
+
scope=SCOPES,
|
72 |
+
message=MISSING_CLIENT_SECRETS_MESSAGE)
|
73 |
+
|
74 |
+
storage = Storage(f"{sys.argv[0]}-oauth2.json")
|
75 |
+
credentials = storage.get()
|
76 |
+
|
77 |
+
if credentials is None or credentials.invalid:
|
78 |
+
flags = argparser.parse_args()
|
79 |
+
credentials = run_flow(flow, storage, flags)
|
80 |
+
|
81 |
+
return build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
|
82 |
+
http=credentials.authorize(httplib2.Http()))
|
83 |
+
|
84 |
+
def initialize_upload(youtube: any, options: dict):
|
85 |
+
"""
|
86 |
+
This method uploads a video to YouTube.
|
87 |
+
|
88 |
+
Args:
|
89 |
+
youtube (any): The authenticated YouTube service.
|
90 |
+
options (dict): The options to upload the video with.
|
91 |
+
|
92 |
+
Returns:
|
93 |
+
response: The response from the upload process.
|
94 |
+
"""
|
95 |
+
|
96 |
+
tags = None
|
97 |
+
if options['keywords']:
|
98 |
+
tags = options['keywords'].split(",")
|
99 |
+
|
100 |
+
body = {
|
101 |
+
'snippet': {
|
102 |
+
'title': options['title'],
|
103 |
+
'description': options['description'],
|
104 |
+
'tags': tags,
|
105 |
+
'categoryId': options['category']
|
106 |
+
},
|
107 |
+
'status': {
|
108 |
+
'privacyStatus': options['privacyStatus'],
|
109 |
+
'madeForKids': False, # Video is not made for kids
|
110 |
+
'selfDeclaredMadeForKids': False # You declare that the video is not made for kids
|
111 |
+
}
|
112 |
+
}
|
113 |
+
|
114 |
+
# Call the API's videos.insert method to create and upload the video.
|
115 |
+
insert_request = youtube.videos().insert(
|
116 |
+
part=",".join(body.keys()),
|
117 |
+
body=body,
|
118 |
+
media_body=MediaFileUpload(options['file'], chunksize=-1, resumable=True)
|
119 |
+
)
|
120 |
+
|
121 |
+
return resumable_upload(insert_request)
|
122 |
+
|
123 |
+
def resumable_upload(insert_request: MediaFileUpload):
|
124 |
+
"""
|
125 |
+
This method implements an exponential backoff strategy to resume a
|
126 |
+
failed upload.
|
127 |
+
|
128 |
+
Args:
|
129 |
+
insert_request (MediaFileUpload): The request to insert the video.
|
130 |
+
|
131 |
+
Returns:
|
132 |
+
response: The response from the upload process.
|
133 |
+
"""
|
134 |
+
response = None
|
135 |
+
error = None
|
136 |
+
retry = 0
|
137 |
+
while response is None:
|
138 |
+
try:
|
139 |
+
print(colored(" => Uploading file...", "magenta"))
|
140 |
+
status, response = insert_request.next_chunk()
|
141 |
+
if 'id' in response:
|
142 |
+
print(f"Video id '{response['id']}' was successfully uploaded.")
|
143 |
+
return response
|
144 |
+
except HttpError as e:
|
145 |
+
if e.resp.status in RETRIABLE_STATUS_CODES:
|
146 |
+
error = f"A retriable HTTP error {e.resp.status} occurred:\n{e.content}"
|
147 |
+
else:
|
148 |
+
raise
|
149 |
+
except RETRIABLE_EXCEPTIONS as e:
|
150 |
+
error = f"A retriable error occurred: {e}"
|
151 |
+
|
152 |
+
if error is not None:
|
153 |
+
print(colored(error, "red"))
|
154 |
+
retry += 1
|
155 |
+
if retry > MAX_RETRIES:
|
156 |
+
raise Exception("No longer attempting to retry.")
|
157 |
+
|
158 |
+
max_sleep = 2 ** retry
|
159 |
+
sleep_seconds = random.random() * max_sleep
|
160 |
+
print(colored(f" => Sleeping {sleep_seconds} seconds and then retrying...", "blue"))
|
161 |
+
time.sleep(sleep_seconds)
|
162 |
+
|
163 |
+
def upload_video(video_path, title, description, category, keywords, privacy_status):
|
164 |
+
try:
|
165 |
+
# Get the authenticated YouTube service
|
166 |
+
youtube = get_authenticated_service()
|
167 |
+
|
168 |
+
# Retrieve and print the channel ID for the authenticated user
|
169 |
+
channels_response = youtube.channels().list(mine=True, part='id').execute()
|
170 |
+
for channel in channels_response['items']:
|
171 |
+
print(colored(f" => Channel ID: {channel['id']}", "blue"))
|
172 |
+
|
173 |
+
# Initialize the upload process
|
174 |
+
video_response = initialize_upload(youtube, {
|
175 |
+
'file': video_path, # The path to the video file
|
176 |
+
'title': title,
|
177 |
+
'description': description,
|
178 |
+
'category': category,
|
179 |
+
'keywords': keywords,
|
180 |
+
'privacyStatus': privacy_status
|
181 |
+
})
|
182 |
+
return video_response # Return the response from the upload process
|
183 |
+
except HttpError as e:
|
184 |
+
print(colored(f"[-] An HTTP error {e.resp.status} occurred:\n{e.content}", "red"))
|
185 |
+
if e.resp.status in [401, 403]:
|
186 |
+
# Here you could refresh the credentials and retry the upload
|
187 |
+
youtube = get_authenticated_service() # This will prompt for re-authentication if necessary
|
188 |
+
video_response = initialize_upload(youtube, {
|
189 |
+
'file': video_path,
|
190 |
+
'title': title,
|
191 |
+
'description': description,
|
192 |
+
'category': category,
|
193 |
+
'keywords': keywords,
|
194 |
+
'privacyStatus': privacy_status
|
195 |
+
})
|
196 |
+
return video_response
|
197 |
+
else:
|
198 |
+
raise e
|
Dockerfile
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.11-slim-buster
|
2 |
+
|
3 |
+
RUN apt-get update && apt-get install --no-install-recommends -y \
|
4 |
+
build-essential autoconf pkg-config wget ghostscript curl libpng-dev
|
5 |
+
|
6 |
+
RUN wget https://github.com/ImageMagick/ImageMagick/archive/refs/tags/7.1.0-31.tar.gz && \
|
7 |
+
tar xzf 7.1.0-31.tar.gz && \
|
8 |
+
rm 7.1.0-31.tar.gz && \
|
9 |
+
apt-get clean && \
|
10 |
+
apt-get autoremove
|
11 |
+
|
12 |
+
RUN sh ./ImageMagick-7.1.0-31/configure --prefix=/usr/local --with-bzlib=yes --with-fontconfig=yes --with-freetype=yes --with-gslib=yes --with-gvc=yes --with-jpeg=yes --with-jp2=yes --with-png=yes --with-tiff=yes --with-xml=yes --with-gs-font-dir=yes && \
|
13 |
+
make -j && make install && ldconfig /usr/local/lib/
|
14 |
+
|
15 |
+
WORKDIR /tmp
|
16 |
+
|
17 |
+
RUN pip install --upgrade pip
|
18 |
+
|
19 |
+
WORKDIR /app
|
20 |
+
|
21 |
+
ADD ./requirements.txt .
|
22 |
+
|
23 |
+
RUN pip install -r requirements.txt
|
EnvironmentVariables.md
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Environment Variables
|
2 |
+
|
3 |
+
## Required
|
4 |
+
|
5 |
+
- TIKTOK_SESSION_ID: Your TikTok session ID is required. Obtain it by logging into TikTok in your browser and copying the value of the `sessionid` cookie.
|
6 |
+
|
7 |
+
- IMAGEMAGICK_BINARY: The filepath to the ImageMagick binary (.exe file) is needed. Obtain it [here](https://imagemagick.org/script/download.php).
|
8 |
+
|
9 |
+
- PEXELS_API_KEY: Your unique Pexels API key is required. Obtain yours [here](https://www.pexels.com/api/).
|
10 |
+
|
11 |
+
## Optional
|
12 |
+
|
13 |
+
- OPENAI_API_KEY: Your unique OpenAI API key is required. Obtain yours [here](https://platform.openai.com/api-keys), only nessecary if you want to use the OpenAI models.
|
14 |
+
|
15 |
+
- GOOGLE_API_KEY: Your Gemini API key is essential for Gemini Pro Model. Generate one securely at [Get API key | Google AI Studio](https://makersuite.google.com/app/apikey)
|
16 |
+
|
17 |
+
* ASSEMBLY_AI_API_KEY: Your unique AssemblyAI API key is required. You can obtain one [here](https://www.assemblyai.com/app/). This field is optional; if left empty, the subtitle will be created based on the generated script. Subtitles can also be created locally.
|
18 |
+
|
19 |
+
Join the [Discord](https://dsc.gg/fuji-community) for support and updates.
|
Frontend/app.js
ADDED
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const videoSubject = document.querySelector("#videoSubject");
|
2 |
+
const aiModel = document.querySelector("#aiModel");
|
3 |
+
const voice = document.querySelector("#voice");
|
4 |
+
const zipUrl = document.querySelector("#zipUrl");
|
5 |
+
const paragraphNumber = document.querySelector("#paragraphNumber");
|
6 |
+
const youtubeToggle = document.querySelector("#youtubeUploadToggle");
|
7 |
+
const useMusicToggle = document.querySelector("#useMusicToggle");
|
8 |
+
const customPrompt = document.querySelector("#customPrompt");
|
9 |
+
const generateButton = document.querySelector("#generateButton");
|
10 |
+
const cancelButton = document.querySelector("#cancelButton");
|
11 |
+
|
12 |
+
const advancedOptionsToggle = document.querySelector("#advancedOptionsToggle");
|
13 |
+
|
14 |
+
advancedOptionsToggle.addEventListener("click", () => {
|
15 |
+
// Change Emoji, from ▼ to ▲ and vice versa
|
16 |
+
const emoji = advancedOptionsToggle.textContent;
|
17 |
+
advancedOptionsToggle.textContent = emoji.includes("▼")
|
18 |
+
? "Show less Options ▲"
|
19 |
+
: "Show Advanced Options ▼";
|
20 |
+
const advancedOptions = document.querySelector("#advancedOptions");
|
21 |
+
advancedOptions.classList.toggle("hidden");
|
22 |
+
});
|
23 |
+
|
24 |
+
|
25 |
+
const cancelGeneration = () => {
|
26 |
+
console.log("Canceling generation...");
|
27 |
+
// Send request to /cancel
|
28 |
+
fetch("http://localhost:8080/api/cancel", {
|
29 |
+
method: "POST",
|
30 |
+
headers: {
|
31 |
+
"Content-Type": "application/json",
|
32 |
+
Accept: "application/json",
|
33 |
+
},
|
34 |
+
})
|
35 |
+
.then((response) => response.json())
|
36 |
+
.then((data) => {
|
37 |
+
alert(data.message);
|
38 |
+
console.log(data);
|
39 |
+
})
|
40 |
+
.catch((error) => {
|
41 |
+
alert("An error occurred. Please try again later.");
|
42 |
+
console.log(error);
|
43 |
+
});
|
44 |
+
|
45 |
+
// Hide cancel button
|
46 |
+
cancelButton.classList.add("hidden");
|
47 |
+
|
48 |
+
// Enable generate button
|
49 |
+
generateButton.disabled = false;
|
50 |
+
generateButton.classList.remove("hidden");
|
51 |
+
};
|
52 |
+
|
53 |
+
const generateVideo = () => {
|
54 |
+
console.log("Generating video...");
|
55 |
+
// Disable button and change text
|
56 |
+
generateButton.disabled = true;
|
57 |
+
generateButton.classList.add("hidden");
|
58 |
+
|
59 |
+
// Show cancel button
|
60 |
+
cancelButton.classList.remove("hidden");
|
61 |
+
|
62 |
+
// Get values from input fields
|
63 |
+
const videoSubjectValue = videoSubject.value;
|
64 |
+
const aiModelValue = aiModel.value;
|
65 |
+
const voiceValue = voice.value;
|
66 |
+
const paragraphNumberValue = paragraphNumber.value;
|
67 |
+
const youtubeUpload = youtubeToggle.checked;
|
68 |
+
const useMusicToggleState = useMusicToggle.checked;
|
69 |
+
const threads = document.querySelector("#threads").value;
|
70 |
+
const zipUrlValue = zipUrl.value;
|
71 |
+
const customPromptValue = customPrompt.value;
|
72 |
+
const subtitlesPosition = document.querySelector("#subtitlesPosition").value;
|
73 |
+
const colorHexCode = document.querySelector("#subtitlesColor").value;
|
74 |
+
|
75 |
+
|
76 |
+
const url = "http://localhost:8080/api/generate";
|
77 |
+
|
78 |
+
// Construct data to be sent to the server
|
79 |
+
const data = {
|
80 |
+
videoSubject: videoSubjectValue,
|
81 |
+
aiModel: aiModelValue,
|
82 |
+
voice: voiceValue,
|
83 |
+
paragraphNumber: paragraphNumberValue,
|
84 |
+
automateYoutubeUpload: youtubeUpload,
|
85 |
+
useMusic: useMusicToggleState,
|
86 |
+
zipUrl: zipUrlValue,
|
87 |
+
threads: threads,
|
88 |
+
subtitlesPosition: subtitlesPosition,
|
89 |
+
customPrompt: customPromptValue,
|
90 |
+
color: colorHexCode,
|
91 |
+
};
|
92 |
+
|
93 |
+
// Send the actual request to the server
|
94 |
+
fetch(url, {
|
95 |
+
method: "POST",
|
96 |
+
body: JSON.stringify(data),
|
97 |
+
headers: {
|
98 |
+
"Content-Type": "application/json",
|
99 |
+
Accept: "application/json",
|
100 |
+
},
|
101 |
+
})
|
102 |
+
.then((response) => response.json())
|
103 |
+
.then((data) => {
|
104 |
+
console.log(data);
|
105 |
+
alert(data.message);
|
106 |
+
// Hide cancel button after generation is complete
|
107 |
+
generateButton.disabled = false;
|
108 |
+
generateButton.classList.remove("hidden");
|
109 |
+
cancelButton.classList.add("hidden");
|
110 |
+
})
|
111 |
+
.catch((error) => {
|
112 |
+
alert("An error occurred. Please try again later.");
|
113 |
+
console.log(error);
|
114 |
+
});
|
115 |
+
};
|
116 |
+
|
117 |
+
generateButton.addEventListener("click", generateVideo);
|
118 |
+
cancelButton.addEventListener("click", cancelGeneration);
|
119 |
+
|
120 |
+
videoSubject.addEventListener("keyup", (event) => {
|
121 |
+
if (event.key === "Enter") {
|
122 |
+
generateVideo();
|
123 |
+
}
|
124 |
+
});
|
125 |
+
|
126 |
+
// Load the data from localStorage on page load
|
127 |
+
document.addEventListener("DOMContentLoaded", (event) => {
|
128 |
+
const voiceSelect = document.getElementById("voice");
|
129 |
+
const storedVoiceValue = localStorage.getItem("voiceValue");
|
130 |
+
|
131 |
+
if (storedVoiceValue) {
|
132 |
+
voiceSelect.value = storedVoiceValue;
|
133 |
+
}
|
134 |
+
});
|
135 |
+
|
136 |
+
// Save the data to localStorage when the user changes the value
|
137 |
+
toggles = ["youtubeUploadToggle", "useMusicToggle", "reuseChoicesToggle"];
|
138 |
+
fields = ["aiModel", "voice", "paragraphNumber", "videoSubject", "zipUrl", "customPrompt", "threads", "subtitlesPosition", "subtitlesColor"];
|
139 |
+
|
140 |
+
document.addEventListener("DOMContentLoaded", () => {
|
141 |
+
toggles.forEach((id) => {
|
142 |
+
const toggle = document.getElementById(id);
|
143 |
+
const storedValue = localStorage.getItem(`${id}Value`);
|
144 |
+
const storedReuseValue = localStorage.getItem("reuseChoicesToggleValue");
|
145 |
+
|
146 |
+
if (toggle && storedValue !== null && storedReuseValue === "true") {
|
147 |
+
toggle.checked = storedValue === "true";
|
148 |
+
}
|
149 |
+
// Attach change listener to update localStorage
|
150 |
+
toggle.addEventListener("change", (event) => {
|
151 |
+
localStorage.setItem(`${id}Value`, event.target.checked);
|
152 |
+
});
|
153 |
+
});
|
154 |
+
|
155 |
+
fields.forEach((id) => {
|
156 |
+
const select = document.getElementById(id);
|
157 |
+
const storedValue = localStorage.getItem(`${id}Value`);
|
158 |
+
const storedReuseValue = localStorage.getItem("reuseChoicesToggleValue");
|
159 |
+
|
160 |
+
if (storedValue && storedReuseValue === "true") {
|
161 |
+
select.value = storedValue;
|
162 |
+
}
|
163 |
+
// Attach change listener to update localStorage
|
164 |
+
select.addEventListener("change", (event) => {
|
165 |
+
localStorage.setItem(`${id}Value`, event.target.value);
|
166 |
+
});
|
167 |
+
});
|
168 |
+
});
|
Frontend/index.html
ADDED
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
+
<title>MoneyPrinter</title>
|
7 |
+
<link
|
8 |
+
rel="icon"
|
9 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💸</text></svg>"
|
10 |
+
/>
|
11 |
+
|
12 |
+
<link
|
13 |
+
rel="stylesheet"
|
14 |
+
href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.2/tailwind.min.css"
|
15 |
+
/>
|
16 |
+
</head>
|
17 |
+
|
18 |
+
<body class="bg-blue-100 min-h-screen justify-center p-40">
|
19 |
+
<h1 class="text-4xl text-center mb-4">MoneyPrinter</h1>
|
20 |
+
<p class="text-center text-gray-700">
|
21 |
+
This Application is intended to automate the creation and uploads of
|
22 |
+
YouTube Shorts.
|
23 |
+
</p>
|
24 |
+
|
25 |
+
<div class="flex justify-center mt-8">
|
26 |
+
<div class="max-w-xl flex flex-col space-y-4 w-full">
|
27 |
+
<label for="videoSubject" class="text-blue-600">Subject</label>
|
28 |
+
<textarea
|
29 |
+
rows="3"
|
30 |
+
type="text"
|
31 |
+
name="videoSubject"
|
32 |
+
id="videoSubject"
|
33 |
+
class="border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
34 |
+
></textarea>
|
35 |
+
<button id="advancedOptionsToggle" class="text-blue-600">
|
36 |
+
Show Advanced Options ▼
|
37 |
+
</button>
|
38 |
+
<div
|
39 |
+
class="flex flex-col space-y-4 hidden transition-all duration-150 linear"
|
40 |
+
id="advancedOptions"
|
41 |
+
>
|
42 |
+
<label for="aiModel" class="text-blue-600">AI Model</label>
|
43 |
+
<select
|
44 |
+
name="aiModel"
|
45 |
+
id="aiModel"
|
46 |
+
class="w-full border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
47 |
+
>
|
48 |
+
<option value="g4f">g4f (Free)</option>
|
49 |
+
<option value="gpt3.5-turbo">OpenAI GPT-3.5</option>
|
50 |
+
<option value="gpt4">OpenAI GPT-4</option>
|
51 |
+
<option value="gemmini">Gemini Pro</option>
|
52 |
+
</select>
|
53 |
+
<label for="voice" class="text-blue-600">Voice</label>
|
54 |
+
<select
|
55 |
+
name="voice"
|
56 |
+
id="voice"
|
57 |
+
class="w-min border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
58 |
+
>
|
59 |
+
<option value="en_us_ghostface">Ghost Face</option>
|
60 |
+
<option value="en_us_chewbacca">Chewbacca</option>
|
61 |
+
<option value="en_us_c3po">C3PO</option>
|
62 |
+
<option value="en_us_stitch">Stitch</option>
|
63 |
+
<option value="en_us_stormtrooper">Stormtrooper</option>
|
64 |
+
<option value="en_us_rocket">Rocket</option>
|
65 |
+
<option value="en_au_001">English AU - Female</option>
|
66 |
+
<option value="en_au_002">English AU - Male</option>
|
67 |
+
<option value="en_uk_001">English UK - Male 1</option>
|
68 |
+
<option value="en_uk_003">English UK - Male 2</option>
|
69 |
+
<option value="en_us_001">English US - Female (Int. 1)</option>
|
70 |
+
<option value="en_us_002">English US - Female (Int. 2)</option>
|
71 |
+
<option value="en_us_006">English US - Male 1</option>
|
72 |
+
<option value="en_us_007">English US - Male 2</option>
|
73 |
+
<option value="en_us_009">English US - Male 3</option>
|
74 |
+
<option value="en_us_010">English US - Male 4</option>
|
75 |
+
<option value="fr_001">French - Male 1</option>
|
76 |
+
<option value="fr_002">French - Male 2</option>
|
77 |
+
<option value="de_001">German - Female</option>
|
78 |
+
<option value="de_002">German - Male</option>
|
79 |
+
<option value="es_002">Spanish - Male</option>
|
80 |
+
<option value="es_mx_002">Spanish MX - Male</option>
|
81 |
+
<option value="br_001">Portuguese BR - Female 1</option>
|
82 |
+
<option value="br_003">Portuguese BR - Female 2</option>
|
83 |
+
<option value="br_004">Portuguese BR - Female 3</option>
|
84 |
+
<option value="br_005">Portuguese BR - Male</option>
|
85 |
+
<option value="id_001">Indonesian - Female</option>
|
86 |
+
<option value="jp_001">Japanese - Female 1</option>
|
87 |
+
<option value="jp_003">Japanese - Female 2</option>
|
88 |
+
<option value="jp_005">Japanese - Female 3</option>
|
89 |
+
<option value="jp_006">Japanese - Male</option>
|
90 |
+
<option value="kr_002">Korean - Male 1</option>
|
91 |
+
<option value="kr_003">Korean - Female</option>
|
92 |
+
<option value="kr_004">Korean - Male 2</option>
|
93 |
+
<option value="en_female_f08_salut_damour">Alto</option>
|
94 |
+
<option value="en_male_m03_lobby">Tenor</option>
|
95 |
+
<option value="en_female_f08_warmy_breeze">Warmy Breeze</option>
|
96 |
+
<option value="en_male_m03_sunshine_soon">Sunshine Soon</option>
|
97 |
+
<option value="en_male_narration">narrator</option>
|
98 |
+
<option value="en_male_funny">wacky</option>
|
99 |
+
<option value="en_female_emotional">peaceful</option>
|
100 |
+
</select>
|
101 |
+
<label for="subtitlesPosition" class="text-blue-600"
|
102 |
+
>Subtitles Position</label
|
103 |
+
>
|
104 |
+
<select
|
105 |
+
name="subtitlesPosition"
|
106 |
+
id="subtitlesPosition"
|
107 |
+
class="w-min border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
108 |
+
>
|
109 |
+
<option value="center,top">Center - Top</option>
|
110 |
+
<option value="center,bottom">Center - Bottom</option>
|
111 |
+
<option value="center,center">Center - Center</option>
|
112 |
+
<option value="left,center">Left - Center</option>
|
113 |
+
<option value="left,bottom">Left - Bottom</option>
|
114 |
+
<option value="right,center">Right - Center</option>
|
115 |
+
<option value="right,bottom">Right - Bottom</option>
|
116 |
+
</select>
|
117 |
+
<label for="subtitlesColor" class="text-blue-600"
|
118 |
+
>Subtitles Color</label>
|
119 |
+
<select
|
120 |
+
name="subtitlesColor"
|
121 |
+
id="subtitlesColor"
|
122 |
+
class="w-min border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
123 |
+
>
|
124 |
+
<option value="#FFFF00">Yellow (Default)</option>
|
125 |
+
<option value="#f4a261">Orange</option>
|
126 |
+
<option value="#e63946">Red</option>
|
127 |
+
<option value="#1d3557">Blue</option>
|
128 |
+
<option value="#fff">White</option>
|
129 |
+
<option value="#03071e">Black</option>
|
130 |
+
</select>
|
131 |
+
<label for="zipUrl" class="text-blue-600"
|
132 |
+
>Zip URL (Leave empty for default)</label
|
133 |
+
>
|
134 |
+
<input
|
135 |
+
type="text"
|
136 |
+
name="zipUrl"
|
137 |
+
id="zipUrl"
|
138 |
+
class="border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
139 |
+
/>
|
140 |
+
<label for="threads" class="text-blue-600">Threads</label>
|
141 |
+
<input
|
142 |
+
type="number"
|
143 |
+
name="threads"
|
144 |
+
id="threads"
|
145 |
+
class="border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
146 |
+
value="2"
|
147 |
+
min="1"
|
148 |
+
max="100"
|
149 |
+
placeholder="2 (Default)"
|
150 |
+
/>
|
151 |
+
<label for="paragraphNumber" class="text-blue-600"
|
152 |
+
>Paragraph Number</label
|
153 |
+
>
|
154 |
+
<input
|
155 |
+
type="number"
|
156 |
+
name="paragraphNumber"
|
157 |
+
id="paragraphNumber"
|
158 |
+
class="border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
159 |
+
value="1"
|
160 |
+
min="1"
|
161 |
+
max="100"
|
162 |
+
/>
|
163 |
+
<label for="customPrompt" class="text-blue-600">Custom Prompt:</label>
|
164 |
+
<textarea
|
165 |
+
rows="3"
|
166 |
+
type="text"
|
167 |
+
name="customPrompt"
|
168 |
+
id="customPrompt"
|
169 |
+
class="border-2 border-blue-300 p-2 rounded-md focus:outline-none focus:border-blue-500"
|
170 |
+
placeholder="only use it if you want to replace the default prompt"
|
171 |
+
></textarea>
|
172 |
+
<label
|
173 |
+
for="youtubeUploadToggle"
|
174 |
+
class="flex items-center text-blue-600"
|
175 |
+
>
|
176 |
+
<input
|
177 |
+
type="checkbox"
|
178 |
+
name="youtubeUploadToggle"
|
179 |
+
id="youtubeUploadToggle"
|
180 |
+
class="mr-2"
|
181 |
+
/>
|
182 |
+
Upload to YouTube
|
183 |
+
</label>
|
184 |
+
<label for="useMusicToggle" class="flex items-center text-blue-600">
|
185 |
+
<input
|
186 |
+
type="checkbox"
|
187 |
+
name="useMusicToggle"
|
188 |
+
id="useMusicToggle"
|
189 |
+
class="mr-2"
|
190 |
+
/>
|
191 |
+
Use Music
|
192 |
+
</label>
|
193 |
+
<label
|
194 |
+
for="reuseChoicesToggle"
|
195 |
+
class="flex items-center text-blue-600"
|
196 |
+
>
|
197 |
+
<input
|
198 |
+
type="checkbox"
|
199 |
+
name="reuseChoicesToggle"
|
200 |
+
id="reuseChoicesToggle"
|
201 |
+
class="mr-2"
|
202 |
+
/>
|
203 |
+
Reuse Choices?
|
204 |
+
</label>
|
205 |
+
</div>
|
206 |
+
<button
|
207 |
+
id="generateButton"
|
208 |
+
class="bg-blue-500 hover:bg-blue-700 duration-100 linear text-white px-4 py-2 rounded-md"
|
209 |
+
>
|
210 |
+
Generate
|
211 |
+
</button>
|
212 |
+
<button
|
213 |
+
id="cancelButton"
|
214 |
+
class="bg-red-500 hover:bg-red-700 duration-100 linear text-white px-4 py-2 rounded-md hidden"
|
215 |
+
>
|
216 |
+
Cancel
|
217 |
+
</button>
|
218 |
+
|
219 |
+
</div>
|
220 |
+
</div>
|
221 |
+
|
222 |
+
<footer class="flex justify-center mt-8">
|
223 |
+
<div class="flex flex-col space-y-4">
|
224 |
+
<p class="text-center text-gray-700">
|
225 |
+
Made with ❤️ by
|
226 |
+
<a
|
227 |
+
class="text-blue-600"
|
228 |
+
target="href"
|
229 |
+
href="https://github.com/FujiwaraChoki"
|
230 |
+
>
|
231 |
+
Fuji Codes
|
232 |
+
</a>
|
233 |
+
</p>
|
234 |
+
</div>
|
235 |
+
</footer>
|
236 |
+
|
237 |
+
<script src="app.js"></script>
|
238 |
+
</body>
|
239 |
+
</html>
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 FujiwaraChoki
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README.md
CHANGED
@@ -1,3 +1,129 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# MoneyPrinter 💸
|
2 |
+
|
3 |
+
THIS IS DEPRECATED; CHECK OUT VERSION 2 [HERE](https://github.com/FujiwaraChoki/MoneyPrinterV2).
|
4 |
+
|
5 |
+
Automate the creation of YouTube Shorts locally, simply by providing a video topic to talk about.
|
6 |
+
|
7 |
+
> **Important** Please make sure you look through existing/closed issues before opening your own. If it's just a question, please join our [discord](https://dsc.gg/fuji-community) and ask there.
|
8 |
+
|
9 |
+
> **🎥** Watch the video on [YouTube](https://youtu.be/mkZsaDA2JnA?si=pNne3MnluRVkWQbE).
|
10 |
+
|
11 |
+
## Installation 📥
|
12 |
+
|
13 |
+
`MoneyPrinter` requires Python 3.11 to run effectively. If you don't have Python installed, you can download it from [here](https://www.python.org/downloads/).
|
14 |
+
|
15 |
+
After you finished installing Python, you can install `MoneyPrinter` by following the steps below:
|
16 |
+
|
17 |
+
```bash
|
18 |
+
git clone https://github.com/FujiwaraChoki/MoneyPrinter.git
|
19 |
+
cd MoneyPrinter
|
20 |
+
|
21 |
+
# Install requirements
|
22 |
+
pip install -r requirements.txt
|
23 |
+
|
24 |
+
# Copy .env.example and fill out values
|
25 |
+
cp .env.example .env
|
26 |
+
|
27 |
+
# Run the backend server
|
28 |
+
cd Backend
|
29 |
+
python main.py
|
30 |
+
|
31 |
+
# Run the frontend server
|
32 |
+
cd ../Frontend
|
33 |
+
python -m http.server 3000
|
34 |
+
```
|
35 |
+
|
36 |
+
See [`.env.example`](.env.example) for the required environment variables.
|
37 |
+
|
38 |
+
If you need help, open [EnvironmentVariables.md](EnvironmentVariables.md) for more information.
|
39 |
+
|
40 |
+
## Usage 🛠️
|
41 |
+
|
42 |
+
1. Copy the `.env.example` file to `.env` and fill in the required values
|
43 |
+
1. Open `http://localhost:3000` in your browser
|
44 |
+
1. Enter a topic to talk about
|
45 |
+
1. Click on the "Generate" button
|
46 |
+
1. Wait for the video to be generated
|
47 |
+
1. The video's location is `MoneyPrinter/output.mp4`
|
48 |
+
|
49 |
+
## Music 🎵
|
50 |
+
|
51 |
+
To use your own music, compress all your MP3 Files into a ZIP file and upload it somewhere. Provide the link to the ZIP file in the Frontend.
|
52 |
+
|
53 |
+
It is recommended to use Services such as [Filebin](https://filebin.net) to upload your ZIP file. If you decide to use Filebin, provide the Frontend with the absolute path to the ZIP file by using More -> Download File, e.g. (use this [Popular TT songs ZIP](https://filebin.net/klylrens0uk2pnrg/drive-download-20240209T180019Z-001.zip), not this [Popular TT songs](https://filebin.net/2avx134kdibc4c3q))
|
54 |
+
|
55 |
+
You can also just move your MP3 files into the `Songs` folder.
|
56 |
+
|
57 |
+
## Fonts 🅰
|
58 |
+
|
59 |
+
Add your fonts to the `fonts/` folder, and load them by specifying the font name on line `124` in `Backend/video.py`.
|
60 |
+
|
61 |
+
## Automatic YouTube Uploading 🎥
|
62 |
+
|
63 |
+
MoneyPrinter now includes functionality to automatically upload generated videos to YouTube.
|
64 |
+
|
65 |
+
To use this feature, you need to:
|
66 |
+
|
67 |
+
1. Create a project inside your Google Cloud Platform -> [GCP](https://console.cloud.google.com/).
|
68 |
+
1. Obtain `client_secret.json` from the project and add it to the Backend/ directory.
|
69 |
+
1. Enable the YouTube v3 API in your project -> [GCP-API-Library](https://console.cloud.google.com/apis/library/youtube.googleapis.com)
|
70 |
+
1. Create an `OAuth consent screen` and add yourself (the account of your YouTube channel) to the testers.
|
71 |
+
1. Enable the following scopes in the `OAuth consent screen` for your project:
|
72 |
+
|
73 |
+
```
|
74 |
+
'https://www.googleapis.com/auth/youtube'
|
75 |
+
'https://www.googleapis.com/auth/youtube.upload'
|
76 |
+
'https://www.googleapis.com/auth/youtubepartner'
|
77 |
+
```
|
78 |
+
|
79 |
+
After this, you can generate the videos and you will be prompted to authenticate yourself.
|
80 |
+
|
81 |
+
The authentication process creates and stores a `main.py-oauth2.json` file inside the Backend/ directory. Keep this file to maintain authentication, or delete it to re-authenticate (for example, with a different account).
|
82 |
+
|
83 |
+
Videos are uploaded as private by default. For a completely automated workflow, change the privacyStatus in main.py to your desired setting ("public", "private", or "unlisted").
|
84 |
+
|
85 |
+
For videos that have been locked as private due to upload via an unverified API service, you will not be able to appeal. You’ll need to re-upload the video via a verified API service or via the YouTube app/site. The unverified API service can also apply for an API audit. So make sure to verify your API, see [OAuth App Verification Help Center](https://support.google.com/cloud/answer/13463073) for more information.
|
86 |
+
|
87 |
+
## FAQ 🤔
|
88 |
+
|
89 |
+
### How do I get the TikTok session ID?
|
90 |
+
|
91 |
+
You can obtain your TikTok session ID by logging into TikTok in your browser and copying the value of the `sessionid` cookie.
|
92 |
+
|
93 |
+
### My ImageMagick binary is not being detected
|
94 |
+
|
95 |
+
Make sure you set your path to the ImageMagick binary correctly in the `.env` file, it should look something like this:
|
96 |
+
|
97 |
+
```env
|
98 |
+
IMAGEMAGICK_BINARY="C:\\Program Files\\ImageMagick-7.1.0-Q16\\magick.exe"
|
99 |
+
```
|
100 |
+
|
101 |
+
Don't forget to use double backslashes (`\\`) in the path, instead of one.
|
102 |
+
|
103 |
+
### I can't install `playsound`: Wheel failed to build
|
104 |
+
|
105 |
+
If you're having trouble installing `playsound`, you can try installing it using the following command:
|
106 |
+
|
107 |
+
```bash
|
108 |
+
pip install -U wheel
|
109 |
+
pip install -U playsound
|
110 |
+
```
|
111 |
+
|
112 |
+
If you were not able to find your solution, please ask in the discord or create a new issue, so that the community can help you.
|
113 |
+
|
114 |
+
## Donate 🎁
|
115 |
+
|
116 |
+
If you like and enjoy `MoneyPrinter`, and would like to donate, you can do that by clicking on the button on the right hand side of the repository. ❤️
|
117 |
+
You will have your name (and/or logo) added to this repository as a supporter as a sign of appreciation.
|
118 |
+
|
119 |
+
## Contributing 🤝
|
120 |
+
|
121 |
+
Pull Requests will not be accepted for the time-being.
|
122 |
+
|
123 |
+
## Star History 🌟
|
124 |
+
|
125 |
+
[![Star History Chart](https://api.star-history.com/svg?repos=FujiwaraChoki/MoneyPrinter&type=Date)](https://star-history.com/#FujiwaraChoki/MoneyPrinter&Date)
|
126 |
+
|
127 |
+
## License 📝
|
128 |
+
|
129 |
+
See [`LICENSE`](LICENSE) file for more information.
|
docker-compose.yml
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version: "3"
|
2 |
+
services:
|
3 |
+
frontend:
|
4 |
+
build:
|
5 |
+
context: .
|
6 |
+
dockerfile: Dockerfile
|
7 |
+
container_name: "frontend"
|
8 |
+
ports:
|
9 |
+
- "8001:8001"
|
10 |
+
command: ["python3", "-m", "http.server", "8001", "--directory", "frontend"]
|
11 |
+
volumes:
|
12 |
+
- ./Frontend:/app/frontend
|
13 |
+
restart: always
|
14 |
+
backend:
|
15 |
+
build:
|
16 |
+
context: .
|
17 |
+
dockerfile: Dockerfile
|
18 |
+
container_name: "backend"
|
19 |
+
ports:
|
20 |
+
- "8080:8080"
|
21 |
+
command: ["python3", "backend/main.py"]
|
22 |
+
volumes:
|
23 |
+
- ./files:/temp
|
24 |
+
- ./Backend:/app/backend
|
25 |
+
- ./fonts:/app/fonts
|
26 |
+
environment:
|
27 |
+
- ASSEMBLY_AI_API_KEY=${ASSEMBLY_AI_API_KEY}
|
28 |
+
- TIKTOK_SESSION_ID=${TIKTOK_SESSION_ID}
|
29 |
+
- IMAGEMAGICK_BINARY=/usr/local/bin/magick
|
30 |
+
- PEXELS_API_KEY=${PEXELS_API_KEY}
|
31 |
+
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
32 |
+
depends_on:
|
33 |
+
- frontend
|
34 |
+
restart: always
|
35 |
+
|
36 |
+
volumes:
|
37 |
+
files:
|
fonts/bold_font.ttf
ADDED
Binary file (28.9 kB). View file
|
|
requirements.txt
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
g4f==0.2.0.7
|
2 |
+
setuptools
|
3 |
+
wheel
|
4 |
+
requests==2.31.0
|
5 |
+
moviepy==1.0.3
|
6 |
+
termcolor==2.4.0
|
7 |
+
flask==3.0.0
|
8 |
+
flask-cors==4.0.0
|
9 |
+
playsound==1.3.0
|
10 |
+
Pillow==9.5.0
|
11 |
+
python-dotenv==1.0.0
|
12 |
+
srt_equalizer==0.1.8
|
13 |
+
platformdirs==4.1.0
|
14 |
+
undetected_chromedriver
|
15 |
+
assemblyai
|
16 |
+
brotli
|
17 |
+
google-api-python-client
|
18 |
+
oauth2client
|
19 |
+
openai
|
20 |
+
google-generativeai
|