├── __init__.py ├── agents ├── __init__.py ├── .gitignore ├── utils.py ├── killer_video_title_gen.py ├── persona_testing.py └── killer_video_idea.py ├── utils ├── __init__.py └── utils.py ├── .env.template ├── requirements.txt ├── avatar_config └── config.json ├── operations ├── __init__.py ├── set_orientation.py ├── subtitles.py ├── denoise.py ├── trim.py ├── save.py ├── transcript.py ├── shorts.py ├── translation.py └── avatar_video_generation.py ├── config_loader.py ├── config.json ├── LICENSE ├── videos_to_compare.json ├── .gitignore ├── recipes.py ├── main.py └── README.md /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agents/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import * 2 | -------------------------------------------------------------------------------- /agents/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | ignore_* 3 | *.ipynb 4 | video_transcription.txt 5 | .env.* -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | OPENAI_MODEL=o4-mini 3 | OPENAI_API_BASE=https://api.openai.com/v1 4 | WHISPER_MODEL_SIZE=turbo -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.26.4 2 | matplotlib==3.10.1 3 | moviepy==1.0.3 4 | denoiser==0.1.5 5 | python-dotenv==1.0.1 6 | faster-whisper==1.1.1 7 | librosa==0.10.2.post1 8 | scipy==1.15.2 9 | pydub==0.25.1 10 | kokoro==0.7.13 11 | soundfile==0.13.1 12 | transformers==4.50.0 13 | sentencepiece==0.1.99 14 | openai==1.68.2 -------------------------------------------------------------------------------- /avatar_config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "avatars": { 3 | "calm": "avatar_config/avatar_calm.mov", 4 | "suspicious": "avatar_config/avatar_sus.mp4", 5 | "angry": "avatar_config/avatar_angry.mp4", 6 | "sad": "avatar_config/avatar_sad.mp4", 7 | "amazed": "avatar_config/avatar_wow.mp4", 8 | "smug": "avatar_config/avatar_smug.mp4" 9 | }, 10 | "shake_factor": 1 11 | } -------------------------------------------------------------------------------- /operations/__init__.py: -------------------------------------------------------------------------------- 1 | from .denoise import * 2 | from .save import * 3 | from .set_orientation import * 4 | from .subtitles import * 5 | from .transcript import * 6 | from .trim import * 7 | from .translation import video_translation, audio_generator 8 | from .shorts import generate_video_base, add_titles 9 | from .avatar_video_generation import ( 10 | create_avatar_video_from_audio as generate_avatar_video, 11 | ) 12 | -------------------------------------------------------------------------------- /config_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to load configuration from a JSON file. 3 | """ 4 | 5 | import json 6 | from pathlib import Path 7 | 8 | 9 | def load_config(config_path: str = "config.json") -> dict: 10 | """ 11 | Load configuration from a JSON file. 12 | """ 13 | config_file = Path(config_path) 14 | if not config_file.exists(): 15 | raise FileNotFoundError(f"Could not find the configuration file: {config_path}") 16 | with config_file.open("r", encoding="utf-8") as f: 17 | config = json.load(f) 18 | return config 19 | 20 | 21 | # Carga la configuración al importar el módulo 22 | config_data = load_config() 23 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "subtitles_clip_config": { 3 | "font": "Hey-Comic", 4 | "fontsize": 60, 5 | "color": "white", 6 | "method": "label", 7 | "align": "south", 8 | "bg_color": "black", 9 | "stroke_color": null, 10 | "stroke_width": null 11 | }, 12 | "subtitles_position": { 13 | "text_position_y_offset": -500, 14 | "text_position_x_offset": 0 15 | }, 16 | "titles_clip_config": { 17 | "font": "Hey-Comic", 18 | "fontsize": 90, 19 | "color": "black", 20 | "method": "label", 21 | "align": "south", 22 | "bg_color": "transparent", 23 | "stroke_color": "black", 24 | "stroke_width": 1.5 25 | }, 26 | "titles_position": { 27 | "text_position_y_offset": 500, 28 | "text_position_x_offset": 0 29 | }, 30 | "titles": [ 31 | "", 32 | "Video completo en la descripcion.", 33 | "Suscribete para mas." 34 | ] 35 | } -------------------------------------------------------------------------------- /operations/set_orientation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to set the orientation of a video clip. 3 | """ 4 | 5 | 6 | def set_vertical(**kwargs): 7 | """ 8 | Set the orientation of a video clip to vertical. 9 | """ 10 | input_video_file_clip = kwargs["input_video_file_clip"] 11 | width, height = input_video_file_clip.size 12 | if width > height: 13 | new_size = (height, width) 14 | input_video_file_clip = input_video_file_clip.resize(new_size) 15 | kwargs["shape"] = input_video_file_clip.size 16 | kwargs["input_video_file_clip"] = input_video_file_clip 17 | return kwargs 18 | 19 | 20 | def set_horizontal(**kwargs): 21 | """ 22 | Set the orientation of a video clip to horizontal. 23 | """ 24 | input_video_file_clip = kwargs["input_video_file_clip"] 25 | width, height = input_video_file_clip.size 26 | if width < height: 27 | new_size = (height, width) 28 | input_video_file_clip = input_video_file_clip.resize(new_size) 29 | kwargs["shape"] = input_video_file_clip.size 30 | kwargs["input_video_file_clip"] = input_video_file_clip 31 | return kwargs 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hector Pulido 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. 22 | -------------------------------------------------------------------------------- /operations/subtitles.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to add subtitles to a video using moviepy. 3 | """ 4 | 5 | import os 6 | from moviepy.editor import TextClip, CompositeVideoClip 7 | from moviepy.video.tools.subtitles import SubtitlesClip 8 | 9 | 10 | def add_subtitles(**kwargs): 11 | """ 12 | Add subtitles to a video clip. 13 | """ 14 | 15 | def generator(txt): 16 | return TextClip(txt, **config_data["subtitles_clip_config"]) 17 | 18 | filename = kwargs["filename"] 19 | input_video_file_clip = kwargs["input_video_file_clip"] 20 | subtitles_filename = kwargs.get( 21 | "transcript_file_name", f"{filename}_transcript.srt" 22 | ) 23 | config_data = kwargs.get("config_data", {}) 24 | if not os.path.exists(subtitles_filename): 25 | subtitles_filename = f"{filename}_transcript.srt" 26 | 27 | subtitles = SubtitlesClip(subtitles_filename, generator) 28 | video_list = [ 29 | input_video_file_clip, 30 | subtitles.set_pos( 31 | ( 32 | "center", 33 | input_video_file_clip.h 34 | + config_data["subtitles_position"]["text_position_y_offset"], 35 | ) 36 | ), 37 | ] 38 | video_with_subs = CompositeVideoClip(video_list) 39 | kwargs["input_video_file_clip"] = video_with_subs 40 | return kwargs 41 | -------------------------------------------------------------------------------- /operations/denoise.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to denoise audio in a video file using the DNS64 model. 3 | """ 4 | 5 | from moviepy import editor 6 | from utils import get_audio 7 | 8 | 9 | def denoise_video(**kwargs): 10 | """ 11 | Denoise the audio of a video file using the DNS64 model. 12 | """ 13 | try: 14 | import torch 15 | import torchaudio 16 | from denoiser import pretrained 17 | from denoiser.dsp import convert_audio 18 | except ImportError as e: 19 | raise ImportError( 20 | "Please install the required libraries: torch, torchaudio, denoiser" 21 | ) from e 22 | 23 | input_video_file_clip, filename = ( 24 | kwargs["input_video_file_clip"], 25 | kwargs["filename"], 26 | ) 27 | audio_file_name = get_audio(input_video_file_clip, filename) 28 | if not audio_file_name: 29 | return kwargs 30 | device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 31 | model = pretrained.dns64().to(device) 32 | wav, source = torchaudio.load(audio_file_name) 33 | wav = convert_audio(wav.to(device), source, model.sample_rate, model.chin) 34 | with torch.no_grad(): 35 | denoised = model(wav[None])[0] 36 | denoised_file_name = f"{filename}_denoised.wav" 37 | torchaudio.save(denoised_file_name, denoised.cpu(), model.sample_rate) 38 | input_video_file_clip.audio = editor.AudioFileClip(denoised_file_name) 39 | kwargs["input_video_file_clip"] = input_video_file_clip 40 | return kwargs 41 | -------------------------------------------------------------------------------- /operations/trim.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to trim a video by silence. 3 | """ 4 | 5 | import logging 6 | import numpy as np 7 | 8 | from utils import get_subclip_volume 9 | 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def trim_by_silence(**kwargs): 16 | """ 17 | Function to trim a video by silence. 18 | """ 19 | input_video_file_clip = kwargs["input_video_file_clip"] 20 | clip_interval = kwargs["clip_interval"] 21 | sound_threshold = kwargs["sound_threshold"] 22 | discard_silence = kwargs["discard_silence"] 23 | logger.info("Chunking video...") 24 | volumes = [] 25 | for i in np.arange(0, input_video_file_clip.duration, clip_interval): 26 | if input_video_file_clip.duration <= i + clip_interval: 27 | continue 28 | logger.info("Processing chunk %s/%s", i, input_video_file_clip.duration) 29 | 30 | volumes.append(get_subclip_volume(input_video_file_clip, i, clip_interval)) 31 | logger.info("Processing silences...") 32 | volumes = np.array(volumes) 33 | volumes_binary = volumes > sound_threshold 34 | change_times = [0] 35 | for i in range(1, len(volumes_binary)): 36 | if volumes_binary[i] != volumes_binary[i - 1]: 37 | change_times.append(i * clip_interval) 38 | change_times.append(input_video_file_clip.duration) 39 | logger.info("Subclipping...") 40 | first_piece_silence = 1 if volumes_binary[0] else 0 41 | clips = [] 42 | for i in range(1, len(change_times)): 43 | if discard_silence and i % 2 != first_piece_silence: 44 | continue 45 | new_clip = input_video_file_clip.subclip(change_times[i - 1], change_times[i]) 46 | clips.append(new_clip) 47 | kwargs["clips"] = clips 48 | return kwargs 49 | -------------------------------------------------------------------------------- /operations/save.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to save video clips using moviepy. 3 | """ 4 | 5 | from moviepy import editor 6 | 7 | 8 | def save_video(**kwargs): 9 | """ 10 | Save a video clip to a file. 11 | """ 12 | filename = kwargs["filename"] 13 | input_video_file_clip = kwargs["input_video_file_clip"] 14 | clip_name = f"{filename}_EDITED.mp4" 15 | input_video_file_clip.write_videofile( 16 | clip_name, 17 | audio_codec="aac", 18 | threads=8, 19 | fps=24, 20 | ) 21 | kwargs["clips_name"] = clip_name 22 | return kwargs 23 | 24 | 25 | def save_joined_video(**kwargs): 26 | """ 27 | Save a joined video clip to a file. 28 | """ 29 | if "clips" not in kwargs: 30 | return save_video(**kwargs) 31 | filename = kwargs["filename"] 32 | clips = kwargs["clips"] 33 | clip_name = f"{filename}_EDITED.mp4" 34 | if isinstance(clips, list): 35 | concat_clip = editor.concatenate_videoclips(clips) 36 | concat_clip.write_videofile( 37 | clip_name, 38 | audio_codec="aac", 39 | threads=8, 40 | fps=24, 41 | ) 42 | kwargs["clips_name"] = clip_name 43 | return kwargs 44 | clips.write_videofile(clip_name, audio_codec="aac") 45 | kwargs["clips_name"] = clip_name 46 | return kwargs 47 | 48 | 49 | def save_separated_video(**kwargs): 50 | """ 51 | Save separated video clips to files. 52 | """ 53 | if "clips" not in kwargs: 54 | return save_video(**kwargs) 55 | filename = kwargs["filename"] 56 | clips = kwargs["clips"] 57 | clips_format = f"{filename}_EDITED_{{i}}.mp4" 58 | for i, clip in enumerate(clips): 59 | pad_i = str(i).zfill(5) 60 | clip.write_videofile(clips_format.format(i=pad_i), audio_codec="aac") 61 | kwargs["clips_name"] = clips_format.format(i="{i}") 62 | return kwargs 63 | -------------------------------------------------------------------------------- /videos_to_compare.json: -------------------------------------------------------------------------------- 1 | { 2 | "iterations": 3, 3 | "ideas_to_generate": 10, 4 | "extra_titles": [ 5 | "La forma mas facil de crear un servidor de SERIES en casa con DOCKER | Odisea Homelab #3" 6 | ], 7 | "user_personas": [ 8 | "Una persona que busca aprender de tecnologia", 9 | "Un CEO de una empresa que quiere entender mejor la tecnologia", 10 | "Un CTO intentando aprender una tecnologia especifica para su equipo", 11 | "Un Desarrollador buscando una tecnologia especifica", 12 | "Un experto en el tema buscando novedades", 13 | "Un estudiante buscando aprender algo nuevo", 14 | "Un chico aburrido buscando algo interesante", 15 | "Alguien que quiere aprender a programar", 16 | "Un abuelo que no entiende nada de tecnologia", 17 | "Un chico gamer con el sueño en su subconsciente de ser programador de videojuegos" 18 | ], 19 | "titles": [ 20 | "Crea y Modifica imágenes con ChatGPT 🔥 (Studio Ghibli y más)", 21 | "¿Que es MCP? : Conecta tu IA a TODO con Este Protocolo Gratuito (Guía Paso a Paso)", 22 | "¿El fin de Data Science? 🤯: Data Science Agent con Gemini + Colab ¡GRATIS!", 23 | "¡Imágenes Hiperrealistas! Tutorial Completo de Flux.1 con ComfyUI ¡GRATIS!", 24 | "Las IAs de VÍDEO tienen un GRAN PROBLEMA...", 25 | "HOY SÍ vas a entender QUÉ es el BLOCKCHAIN - (Bitcoin, Cryptos, NFTs y", 26 | "¡Aumentando FOTOGRAMAS con Inteligencia Artificial! (SuperFluidez)", 27 | "TUTORIAL 👉 ¡Entrena a la IA con tu CARA! - 100% GRATIS Y SIN GPUs (Stable Diffusion", 28 | "¿Cuánto Tarda Esta IA En Aprender A Manejar?", 29 | "Tu primera red neuronal en Python y Tensorflow", 30 | "El Software Que Mató A 346 Personas", 31 | "CREAR nuestra primera RED NEURONAL en C# !!! [Perceptron simple]", 32 | "🤖 Hice un ASISTENTE VIRTUAL POR VOZ!!! ► [chatbot con voz en 15 minutos]", 33 | "3 proyectos de ALGORITMOS GENÉTICOS en C# UNITY ► NO VAS A CREER EL ULTIMO" 34 | ] 35 | } -------------------------------------------------------------------------------- /operations/transcript.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains functions to generate transcripts from video files. 3 | """ 4 | 5 | from faster_whisper import WhisperModel 6 | from utils import get_audio, float_to_srt_time 7 | 8 | 9 | MODEL_SIZE = "turbo" 10 | 11 | 12 | def generate_transcript(**kwargs): 13 | """ 14 | Generates a transcript from the input video file and saves it as an SRT file. 15 | """ 16 | input_video_file_clip, filename = ( 17 | kwargs["input_video_file_clip"], 18 | kwargs["filename"], 19 | ) 20 | audio_file_name = get_audio(input_video_file_clip, filename) 21 | if not audio_file_name: 22 | return kwargs 23 | model = WhisperModel(MODEL_SIZE, num_workers=4, compute_type="int8") 24 | segments, _ = model.transcribe(audio_file_name, multilingual=True) 25 | transcript = "" 26 | for segment in segments: 27 | start_time = float_to_srt_time(segment.start) 28 | end_time = float_to_srt_time(segment.end) 29 | text_data = segment.text.strip() 30 | transcript += f"{segment.id + 1}\n{start_time} --> {end_time}\n{text_data}\n\n" 31 | transcript_file_name = f"{filename}_transcript.srt" 32 | with open(transcript_file_name, "w", encoding="utf-8") as file: 33 | file.write(transcript) 34 | kwargs["transcript_file_name"] = transcript_file_name 35 | return kwargs 36 | 37 | 38 | def generate_transcript_divided(**kwargs): 39 | """ 40 | Generates a transcript from the input video file and saves it as an SRT file. 41 | The transcript is divided into segments based on word timestamps. 42 | """ 43 | input_video_file_clip, filename = ( 44 | kwargs["input_video_file_clip"], 45 | kwargs["filename"], 46 | ) 47 | audio_file_name = get_audio(input_video_file_clip, filename) 48 | if not audio_file_name: 49 | return kwargs 50 | model = WhisperModel(MODEL_SIZE, num_workers=4, compute_type="int8") 51 | segments, _ = model.transcribe( 52 | audio_file_name, multilingual=True, word_timestamps=True 53 | ) 54 | transcript = "" 55 | segment_id = 1 56 | 57 | for segment in segments: 58 | for word in segment.words: 59 | start_time = float_to_srt_time(word.start) 60 | end_time = float_to_srt_time(word.end) 61 | text_data = word.word.strip() 62 | segment_id += 1 63 | transcript += f"{segment_id}\n{start_time} --> {end_time}\n{text_data}\n\n" 64 | 65 | transcript_file_name = f"{filename}_transcript.srt" 66 | with open(transcript_file_name, "w", encoding="utf-8") as file: 67 | file.write(transcript) 68 | kwargs["transcript_file_name"] = transcript_file_name 69 | return kwargs 70 | -------------------------------------------------------------------------------- /operations/shorts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to generate a video with a blurred background and add titles. 3 | """ 4 | 5 | import os 6 | import logging 7 | from pathlib import Path 8 | 9 | from moviepy import editor 10 | from moviepy.editor import ColorClip, CompositeVideoClip, VideoFileClip 11 | 12 | from config_loader import config_data 13 | 14 | logging.basicConfig(level=logging.INFO) 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def blur_video(video_path: str) -> str: 19 | """ 20 | Blurs the video and saves it with a new name. 21 | """ 22 | new_video_path = f"blurred_{Path(video_path).name}" 23 | with VideoFileClip(video_path) as video: 24 | video_wo_audio = video.without_audio() 25 | video_wo_audio.write_videofile( 26 | new_video_path, 27 | ffmpeg_params=["-vf", "boxblur=10:1"], 28 | preset="ultrafast", 29 | threads=8, 30 | fps=24, 31 | codec="libx264", 32 | ) 33 | return new_video_path 34 | 35 | 36 | def generate_video_base(video_path_data: str, video_size=(1080, 1920)): 37 | """ 38 | Generates a base video with a blurred background and the original video on top. 39 | """ 40 | video_path_output = f"output_{Path(video_path_data).name}" 41 | blurred_video_name = blur_video(video_path_data) 42 | blurred_video = VideoFileClip(blurred_video_name).resize(height=video_size[1]) 43 | video = VideoFileClip(video_path_data).resize(width=video_size[0]) 44 | video_base = ColorClip(video_size, color=(0, 0, 0)).set_duration(video.duration) 45 | composite = CompositeVideoClip( 46 | [video_base, blurred_video.set_position("center"), video.set_position("center")] 47 | ).set_duration(video.duration) 48 | composite.write_videofile( 49 | video_path_output, preset="ultrafast", threads=8, fps=24, codec="libx264" 50 | ) 51 | os.remove(blurred_video_name) 52 | logger.info("Base video generated on %s", video_path_output) 53 | 54 | 55 | def add_titles(video_path: str): 56 | """ 57 | Adds titles to the video. 58 | """ 59 | video = VideoFileClip(video_path) 60 | title_clips = [] 61 | duration = 3 # Duración de cada título 62 | for title in config_data.get("titles", []): 63 | if not title or not title.strip(): 64 | continue 65 | title_clip = editor.TextClip( 66 | title, **config_data["titles_clip_config"] 67 | ).set_duration(duration) 68 | pos = ("center", config_data["titles_position"]["text_position_y_offset"]) 69 | title_clip = title_clip.set_position(pos) 70 | title_clips.append(title_clip) 71 | if not title_clips: 72 | logger.info("No titles to add.") 73 | return 74 | 75 | final_clip = editor.concatenate_videoclips(title_clips + [video]) 76 | output_path = f"output_titles_{Path(video_path).name}" 77 | final_clip.write_videofile( 78 | output_path, preset="ultrafast", threads=8, fps=24, codec="libx264" 79 | ) 80 | logger.info("Video with titles saved at: %s", output_path) 81 | -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to handle video processing and audio extraction. 3 | """ 4 | 5 | import os 6 | import argparse 7 | from pathlib import Path 8 | 9 | import numpy as np 10 | from moviepy.editor import VideoFileClip 11 | 12 | 13 | def str2bool(v): 14 | """ 15 | Convert a string to a boolean value. 16 | """ 17 | if isinstance(v, bool): 18 | return v 19 | if v.lower() in ("yes", "true", "t", "y", "1"): 20 | return True 21 | if v.lower() in ("no", "false", "f", "n", "0"): 22 | return False 23 | raise argparse.ArgumentTypeError("Boolean value expected.") 24 | 25 | 26 | def get_subclip_volume(subclip, second, interval): 27 | """ 28 | Get the volume of a subclip. 29 | """ 30 | cut = subclip.subclip(second, second + interval).audio.to_soundarray(fps=44100) 31 | return np.sqrt(((1.0 * cut) ** 2).mean()) 32 | 33 | 34 | def get_subclip_volume_segment(audio_segment, start: float, duration: float) -> float: 35 | """ 36 | Get the volume of a segment of an audio file. 37 | """ 38 | start_ms = int(start * 1000) 39 | end_ms = int((start + duration) * 1000) 40 | segment = audio_segment[start_ms:end_ms] 41 | return segment.rms 42 | 43 | 44 | def float_to_srt_time(seconds: float) -> str: 45 | """ 46 | Convert a float to SRT time format. 47 | """ 48 | hours = int(seconds // 3600) 49 | minutes = int((seconds % 3600) // 60) 50 | sec = int(seconds % 60) 51 | milliseconds = int((seconds - int(seconds)) * 1000) 52 | return f"{hours:02d}:{minutes:02d}:{sec:02d},{milliseconds:03d}" 53 | 54 | 55 | def get_audio(input_video_file_clip, filename: str) -> str | None: 56 | """ 57 | Extract audio from a video file and save it as a WAV file. 58 | """ 59 | base = Path(filename) 60 | audio_file_name = f"{base}_audio.wav" 61 | audio_path = Path(audio_file_name) 62 | if audio_path.exists(): 63 | audio_path.unlink() 64 | try: 65 | input_video_file_clip.audio.write_audiofile(str(audio_path), codec="pcm_s16le") 66 | except AttributeError: 67 | return None 68 | return str(audio_path) 69 | 70 | 71 | def get_video_data(**kwargs): 72 | """ 73 | Get video data from the input video file. 74 | """ 75 | video_path = kwargs["video_path"] 76 | filename = os.path.splitext(os.path.basename(video_path))[0] 77 | input_video_file_clip = VideoFileClip(video_path) 78 | kwargs["shape"] = input_video_file_clip.size 79 | kwargs["filename"] = filename 80 | kwargs["input_video_file_clip"] = input_video_file_clip 81 | return kwargs 82 | 83 | 84 | def apply_shake(clip, shake_intensity: float): 85 | """ 86 | Apply shake effect to a clip. 87 | The image is randomly shifted in x and y according to the intensity. 88 | """ 89 | 90 | def shake_transform(get_frame, t): 91 | frame = get_frame(t) 92 | dx = int(np.random.uniform(-shake_intensity, shake_intensity)) 93 | dy = int(np.random.uniform(-shake_intensity, shake_intensity)) 94 | shaken_frame = np.roll(frame, dx, axis=1) 95 | shaken_frame = np.roll(shaken_frame, dy, axis=0) 96 | return shaken_frame 97 | 98 | return clip.fl(shake_transform) 99 | -------------------------------------------------------------------------------- /agents/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for the agents 3 | """ 4 | 5 | import json 6 | import re 7 | from openai import OpenAI 8 | import requests 9 | 10 | 11 | def try_to_load_json(_client: OpenAI, model: str, json_string: str) -> dict | list: 12 | """ 13 | Try to load a JSON string. If it fails, it will try to fix the JSON string 14 | """ 15 | 16 | json_prompt = """ 17 | This is a JSON string, but it is not well formatted. delete everything that is not JSON, fix any possible formatting issue and return only the JSON string. without text, without explanation, ``` or anything else. 18 | """ 19 | 20 | try: 21 | return json.loads(json_string) 22 | except json.JSONDecodeError: 23 | # Si no se puede cargar como JSON, intenta corregirlo 24 | response = _client.chat.completions.create( 25 | model=model, 26 | messages=[{"role": "user", "content": json_prompt + json_string}], 27 | ) 28 | try: 29 | return json.loads(response.choices[0].message.content.strip()) 30 | except json.JSONDecodeError: 31 | return {} 32 | 33 | 34 | def get_youtube_data(youtube_username): 35 | """ 36 | Get YouTube data from a user's channel. 37 | """ 38 | regex = r'""([\sa-zA-Z0-9áéíóúÁÉÍÓÚ]+)""' 39 | replacement = r'"\1"' 40 | 41 | headers = { 42 | "User-Agent": ( 43 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " 44 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36" 45 | ) 46 | } 47 | 48 | url = f"https://www.youtube.com/{youtube_username}/videos" 49 | page = requests.get(url, timeout=5, headers=headers) 50 | html_str = page.content.decode("utf-8") 51 | 52 | json_string = html_str.split("var ytInitialData = ")[-1].split(";")[0] 53 | cleaned_json_string = json_string.replace("\n", " ").replace("\r", " ") 54 | cleaned_json_string = re.sub(regex, replacement, cleaned_json_string) 55 | json_data = json.loads(cleaned_json_string, strict=False) 56 | 57 | video_list = [] 58 | tabs = json_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"] 59 | for tab in tabs: 60 | if tab.get("tabRenderer", {}).get("title", "").lower() not in [ 61 | "videos", 62 | "vídeos", 63 | "video", 64 | ]: 65 | continue 66 | for video in tab["tabRenderer"]["content"]["richGridRenderer"]["contents"]: 67 | video_data = {} 68 | if "richItemRenderer" not in video: 69 | continue 70 | video_data["title"] = video["richItemRenderer"]["content"]["videoRenderer"][ 71 | "title" 72 | ]["runs"][0]["text"] 73 | video_data["id"] = video["richItemRenderer"]["content"]["videoRenderer"][ 74 | "videoId" 75 | ] 76 | video_data["url"] = f"https://www.youtube.com/watch?v={video_data['id']}" 77 | video_data[ 78 | "thumbnail" 79 | ] = f"https://img.youtube.com/vi/{video_data['id']}/0.jpg" 80 | video_data["published"] = video["richItemRenderer"]["content"][ 81 | "videoRenderer" 82 | ]["publishedTimeText"]["simpleText"] 83 | video_data["viewCountText"] = video["richItemRenderer"]["content"][ 84 | "videoRenderer" 85 | ]["viewCountText"]["simpleText"] 86 | video_list.append(video_data) 87 | break 88 | return video_list 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.wav 2 | *.mp4 3 | *.mov 4 | *.srt 5 | *.mp3 6 | *.jpe?g 7 | *.png 8 | .DS_Store 9 | *_segments.json 10 | output.json 11 | output.txt 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | cover/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | db.sqlite3 74 | db.sqlite3-journal 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # poetry 110 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 111 | # This is especially recommended for binary packages to ensure reproducibility, and is more 112 | # commonly ignored for libraries. 113 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 114 | #poetry.lock 115 | 116 | # pdm 117 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 118 | #pdm.lock 119 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 120 | # in version control. 121 | # https://pdm.fming.dev/#use-with-ide 122 | .pdm.toml 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ -------------------------------------------------------------------------------- /agents/killer_video_title_gen.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script generates killer video titles for YouTube videos using OpenAI's API. 3 | """ 4 | 5 | import os 6 | import sys 7 | import json 8 | 9 | from dotenv import load_dotenv 10 | from openai import OpenAI 11 | 12 | try: 13 | from .utils import try_to_load_json 14 | from .persona_testing import PersonaTester 15 | except ImportError: 16 | from utils import try_to_load_json 17 | from persona_testing import PersonaTester 18 | 19 | load_dotenv() 20 | OPENAI_MODEL = os.getenv("OPENAI_MODEL", "o3-mini") 21 | OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") 22 | OPENAI_API_BASE = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1") 23 | _client = OpenAI( 24 | api_key=OPENAI_API_KEY, 25 | base_url=OPENAI_API_BASE, 26 | ) 27 | 28 | 29 | def gen_video_summary(videos_string: str) -> str: 30 | """ 31 | Take the description (videos_string) of the existing YouTuber videos 32 | and generate a brief summary about their style. 33 | """ 34 | prompt = f""" 35 | Your objective is to summarize this video on it's original language 36 | The summary must describe the video, give it's structure and extract interesting quotes 37 | The summary must be on the video language 38 | 39 | 40 | {videos_string} 41 | 42 | 43 | Return only a plain text with the summary and nothing else 44 | """.strip() 45 | response = _client.chat.completions.create( 46 | model=OPENAI_MODEL, 47 | messages=[{"role": "user", "content": prompt}], 48 | ) 49 | return response.choices[0].message.content.strip() 50 | 51 | 52 | def gen_video_titles(videos_string: str, num_of_titles: int) -> str: 53 | """ 54 | Generate N titles for the given title 55 | """ 56 | prompt = f""" 57 | You are a world-famous YouTuber and copywritter, you master the creation of viral content, high-CTR YouTube video titles. 58 | Your objective is to generate {str(num_of_titles)} PERFECT YouTube video titles for the following video description. 59 | 60 | 61 | - Titles must be SHORT (ideal: 50 characters or less, maximum: 70). 62 | - Use SIMPLE, powerful language (5th-grade reading level). 63 | - Create **clear titles, not vague ones**. They must instantly communicate what the video is about. 64 | - Optimize for **SEO keywords** relevant to the content. 65 | - Place the most important words at the **beginning** of the title. 66 | - Use proven high-performing formats when possible: 67 | • “How to …” 68 | • “Top 10 …” / “Best …” 69 | • “The Truth About …” 70 | • “I Tried … So You Don’t Have To” 71 | • “What Happens If …” 72 | • “The Secret to …” 73 | - Avoid clickbait that deceives viewers. Generate **curiosity without confusion**. 74 | - Titles should **work together with the thumbnail** to create intrigue and urgency. 75 | - Always use the same language as the video description. 76 | 77 | 78 | 79 | {videos_string} 80 | 81 | 82 | Return only a JSON list of {str(num_of_titles)} killer YouTube video titles: 83 | 84 | [ 85 | "My killer video title 1", 86 | "My killer video title 2", 87 | ... 88 | ] 89 | """.strip() 90 | response = _client.chat.completions.create( 91 | model=OPENAI_MODEL, 92 | messages=[{"role": "user", "content": prompt}], 93 | ) 94 | return response.choices[0].message.content.strip() 95 | 96 | 97 | if __name__ == "__main__": 98 | if len(sys.argv) < 2 or len(sys.argv) > 2: 99 | print("Usage: python killer_video_title_gen.py ") 100 | sys.exit(1) 101 | 102 | video_path = sys.argv[1] 103 | 104 | with open(video_path, "r", encoding="utf-8") as file: 105 | video_transcription = file.read() 106 | 107 | summary = gen_video_summary(video_transcription) 108 | 109 | persona_tester = PersonaTester( 110 | model=OPENAI_MODEL, 111 | client=_client, 112 | comparation_path="videos_to_compare.json", 113 | ) 114 | titles = gen_video_titles(summary, persona_tester.ideas_to_generate) 115 | titles = try_to_load_json(_client, OPENAI_MODEL, titles) 116 | print("\n=== INITIAL VIDEO TITLES ===") 117 | print(titles) 118 | 119 | titles_results = persona_tester.test_multiples_videos( 120 | titles, 121 | checks=100, 122 | use_extra_titles=True, 123 | ) 124 | 125 | print(f"\n=== {persona_tester.ideas_to_generate} FINAL VIDEO TITLES ===") 126 | print(titles_results) 127 | 128 | with open("output.json", "w", encoding="utf-8") as file: 129 | file.write(json.dumps(titles_results, ensure_ascii=False, indent=4)) 130 | -------------------------------------------------------------------------------- /recipes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | This script provides a command line interface for various video processing tasks. 4 | """ 5 | import subprocess 6 | import sys 7 | import os 8 | 9 | 10 | def transcribe_video(video: str): 11 | """ 12 | Transcribes the video using the 'video_transcription' pipeline. 13 | Command: 14 | python main.py video_edit {video} --pipeline transcript 15 | """ 16 | 17 | command = f"python main.py video_edit {video} --pipeline transcript" 18 | subprocess.run(command, shell=True, check=True) 19 | 20 | 21 | def separate_video(video: str): 22 | """ 23 | Separates the video using the 'trim_by_silence' pipeline. 24 | Command: 25 | python main.py video_edit {video} --pipeline \ 26 | trim_by_silence save_separated_video -c 0.25 -s 0.01 -d True 27 | """ 28 | command = f"python main.py video_edit {video} --pipeline \ 29 | trim_by_silence save_separated_video -c 0.25 -s 0.001 -d True" 30 | subprocess.run(command, shell=True, check=True) 31 | 32 | 33 | def generate_avatar(video: str): 34 | """ 35 | Generates the video avatar. 36 | Command: 37 | python main.py avatar_video_generation {video} avatar_config/config.json 38 | """ 39 | command = ( 40 | f"python main.py avatar_video_generation {video} avatar_config/config.json" 41 | ) 42 | subprocess.run(command, shell=True, check=True) 43 | 44 | 45 | def subtitle_video(video: str): 46 | """ 47 | Generate subtitles and add them to the video 48 | """ 49 | try: 50 | command = f"python main.py video_edit {video} --pipeline transcript_divided" 51 | subprocess.run(command, shell=True, check=True) 52 | except subprocess.CalledProcessError: 53 | return 54 | 55 | command = f"python main.py video_edit {video} --pipeline subtitles save_join" 56 | subprocess.run(command, shell=True, check=True) 57 | 58 | 59 | def generate_short_base(video: str): 60 | """ 61 | Generates a short base from the video by chaining several commands: 62 | 1. Divides the transcript: 63 | python main.py video_edit {video} --pipeline transcript_divided 64 | 2. Renames the subtitle file: 65 | mv {base_name}_transcript.srt output_{base_name}_transcript.srt 66 | 3. Generates the base: 67 | python main.py generator {video} base 68 | 4. Joins the subtitles: 69 | python main.py video_edit {video} --pipeline subtitles save_join 70 | """ 71 | # Get the base name of the video (without extension) 72 | base_name = os.path.splitext(video)[0] 73 | 74 | try: 75 | command = ( 76 | f"python main.py video_edit {video} --pipeline transcript_divided &&" 77 | f"mv {base_name}_transcript.srt output_{base_name}_transcript.srt" 78 | ) 79 | subprocess.run(command, shell=True, check=True) 80 | except subprocess.CalledProcessError: 81 | command = f"python main.py generator {video} base && \ 82 | python main.py video_edit output_{video} --pipeline save_join" 83 | subprocess.run(command, shell=True, check=True) 84 | return 85 | command = ( 86 | f"python main.py generator {video} base && " 87 | f"python main.py video_edit output_{video} --pipeline subtitles save_join" 88 | ) 89 | subprocess.run(command, shell=True, check=True) 90 | 91 | 92 | def generate_video_ideas(youtube_username: str): 93 | """ 94 | Generate video ideas from a youtube channel username 95 | """ 96 | print("Remember to change the videos_to_compare.json") 97 | command = f"python agents/killer_video_idea.py {youtube_username}" 98 | subprocess.run(command, shell=True, check=True) 99 | 100 | 101 | def generate_video_title(video: str): 102 | """ 103 | Generate video title idea using the transcript 104 | 1. Check if {video}_transcript.srt exists 105 | 2 If exists, just return the response of: 106 | python agents/killer_video_title_gen.py {video}_transcript.srt 107 | 3 If not 108 | 4. Generate video transcript 109 | 5. Generate titles with python agents/killer_video_title_gen.py {video}_transcript.srt 110 | """ 111 | print("Remember to change the videos_to_compare.json") 112 | 113 | def killer_video_title(transcript_name): 114 | command = f"python agents/killer_video_title_gen.py {transcript_name}" 115 | subprocess.run(command, shell=True, check=True) 116 | print("Output saved on output.txt") 117 | 118 | base_name = os.path.splitext(video)[0] 119 | transcript_name = f"{base_name}_transcript.srt" 120 | 121 | if os.path.isfile(transcript_name): 122 | killer_video_title(transcript_name) 123 | return 124 | 125 | transcribe_video(video) 126 | killer_video_title(transcript_name) 127 | 128 | 129 | def main(): 130 | """ 131 | Main function to handle command line arguments and execute the appropriate function. 132 | """ 133 | if len(sys.argv) < 3: 134 | print("Usage: python recipes.py