├── .gitignore ├── examples ├── The Dark Stairway.mp4 ├── The Phantom Dater.mp4 └── The Cursed Depths of Blackwood Forest.mp4 ├── __pycache__ └── tiktokvoice.cpython-310.pyc ├── README.md ├── tiktokvoice.py └── create_story.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /examples/The Dark Stairway.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azzedde/story_videos_generator/HEAD/examples/The Dark Stairway.mp4 -------------------------------------------------------------------------------- /examples/The Phantom Dater.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azzedde/story_videos_generator/HEAD/examples/The Phantom Dater.mp4 -------------------------------------------------------------------------------- /__pycache__/tiktokvoice.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azzedde/story_videos_generator/HEAD/__pycache__/tiktokvoice.cpython-310.pyc -------------------------------------------------------------------------------- /examples/The Cursed Depths of Blackwood Forest.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azzedde/story_videos_generator/HEAD/examples/The Cursed Depths of Blackwood Forest.mp4 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Story Videos Generator 🎥🖋️ 2 | 3 | **Story_Videos_Generator** is a Python project that automatically generates random suspenseful and horror stories, blending creativity with AI! Using **GPT-4o-mini** for storytelling and **LangChain** for orchestration, it creates chilling plots, while **Flux[Schnell]** generates eerie images to match the tone of the stories. 🖼️ 4 | 5 | The entire workflow, from story creation to video editing, is automated with **movie.py**, which produces high-quality videos ready for posting. Note that the inference may take some time since it runs via GitHub's endpoint (no GPU required!). 🎬 6 | 7 | ## Features 8 | - **Story Generation**: Automatically creates captivating suspense and horror stories. 9 | - **Image Generation**: Uses the powerful Flux[Schnell] model for generating vivid story-related images. 10 | - **Video Editing**: Automatically composes the final video with minimal intervention, including audio and visual elements. 11 | - **Ready for Sharing**: The final output is a polished video that you can instantly share or post. 12 | 13 | ## How to Use 14 | 1. Clone the repository and add your OpenAI API key and HuggingFace token to the `.env` file. 15 | 2. Run the `create_story.py` script. 16 | 3. A directory will be created with the title of your story. Inside, you'll find: 17 | - The generated script 📜 18 | - The images used 🖼️ 19 | - The soundtrack 🎶 20 | 4. The final video will appear in the main directory, ready to watch or share! 🎥 21 | 22 | ## Collaboration 23 | 🚀 Want to improve the project or collaborate on similar ones? Feel free to reach out or explore my other [LLM projects](https://github.com/Azzedde). Together, we can make even better AI-generated content! 24 | 25 | -------------------------------------------------------------------------------- /tiktokvoice.py: -------------------------------------------------------------------------------- 1 | # author: Giorgio 2 | # date: 26.03.2024 3 | # topic: TikTok-Voice-TTS 4 | # version: 1.2 5 | 6 | import requests, base64, re, sys 7 | from threading import Thread 8 | from playsound import playsound 9 | 10 | # define the endpoint data with URLs and corresponding response keys 11 | ENDPOINT_DATA = [ 12 | { 13 | "url": "https://tiktok-tts.weilnet.workers.dev/api/generation", 14 | "response": "data" 15 | }, 16 | { 17 | "url": "https://countik.com/api/text/speech", 18 | "response": "v_data" 19 | }, 20 | { 21 | "url": "https://gesserit.co/api/tiktok-tts", 22 | "response": "base64" 23 | } 24 | ] 25 | 26 | # define available voices for text-to-speech conversion 27 | VOICES = [ 28 | # DISNEY VOICES 29 | 'en_us_ghostface', # Ghost Face 30 | 'en_us_chewbacca', # Chewbacca 31 | 'en_us_c3po', # C3PO 32 | 'en_us_stitch', # Stitch 33 | 'en_us_stormtrooper', # Stormtrooper 34 | 'en_us_rocket', # Rocket 35 | # ENGLISH VOICES 36 | 'en_au_001', # English AU - Female 37 | 'en_au_002', # English AU - Male 38 | 'en_uk_001', # English UK - Male 1 39 | 'en_uk_003', # English UK - Male 2 40 | 'en_us_001', # English US - Female (Int. 1) 41 | 'en_us_002', # English US - Female (Int. 2) 42 | 'en_us_006', # English US - Male 1 43 | 'en_us_007', # English US - Male 2 44 | 'en_us_009', # English US - Male 3 45 | 'en_us_010', # English US - Male 4 46 | # EUROPE VOICES 47 | 'fr_001', # French - Male 1 48 | 'fr_002', # French - Male 2 49 | 'de_001', # German - Female 50 | 'de_002', # German - Male 51 | 'es_002', # Spanish - Male 52 | # AMERICA VOICES 53 | 'es_mx_002', # Spanish MX - Male 54 | 'br_001', # Portuguese BR - Female 1 55 | 'br_003', # Portuguese BR - Female 2 56 | 'br_004', # Portuguese BR - Female 3 57 | 'br_005', # Portuguese BR - Male 58 | # ASIA VOICES 59 | 'id_001', # Indonesian - Female 60 | 'jp_001', # Japanese - Female 1 61 | 'jp_003', # Japanese - Female 2 62 | 'jp_005', # Japanese - Female 3 63 | 'jp_006', # Japanese - Male 64 | 'kr_002', # Korean - Male 1 65 | 'kr_003', # Korean - Female 66 | 'kr_004', # Korean - Male 2 67 | # SINGING VOICES 68 | 'en_female_f08_salut_damour', # Alto 69 | 'en_male_m03_lobby', # Tenor 70 | 'en_female_f08_warmy_breeze', # Warmy Breeze 71 | 'en_male_m03_sunshine_soon', # Sunshine Soon 72 | # OTHER 73 | 'en_male_narration', # narrator 74 | 'en_male_funny', # wacky 75 | 'en_female_emotional', # peaceful 76 | ] 77 | 78 | # define the text-to-speech function 79 | def tts(text: str, voice: str, output_filename: str = "output.mp3", play_sound: bool = False) -> None: 80 | # specified voice is valid 81 | if not voice in VOICES: 82 | raise ValueError("voice must be valid") 83 | 84 | # text is not empty 85 | if not text: 86 | raise ValueError("text must not be 'None'") 87 | 88 | # split the text into chunks 89 | chunks: list[str] = _split_text(text) 90 | 91 | for entry in ENDPOINT_DATA: 92 | endpoint_valid: bool = True 93 | 94 | # empty list to store the data from the reqeusts 95 | audio_data: list[str] = ["" for i in range(len(chunks))] 96 | 97 | # generate audio for each chunk in a separate thread 98 | def generate_audio_chunk(index: int, chunk: str) -> None: 99 | nonlocal endpoint_valid 100 | 101 | if not endpoint_valid: return 102 | 103 | try: 104 | # request to the endpoint to generate audio for the chunk 105 | response = requests.post( 106 | entry["url"], 107 | json={ 108 | "text": chunk, 109 | "voice": voice 110 | } 111 | ) 112 | 113 | if response.status_code == 200: 114 | # store the audio data for the chunk 115 | audio_data[index] = response.json()[entry["response"]] 116 | else: 117 | endpoint_valid = False 118 | 119 | except requests.RequestException as e: 120 | print(f"Error: {e}") 121 | sys.exit() 122 | 123 | # start threads for generating audio for each chunk 124 | threads: list[Thread] = [] 125 | for index, chunk in enumerate(chunks): 126 | thread: Thread = Thread(target=generate_audio_chunk, args=(index, chunk)) 127 | threads.append(thread) 128 | thread.start() 129 | 130 | # wait for all threads to finish 131 | for thread in threads: 132 | thread.join() 133 | 134 | if not endpoint_valid: continue 135 | 136 | # concatenate audio data from all chunks and decode from base64 137 | audio_bytes = base64.b64decode("".join(audio_data)) 138 | 139 | # write the audio data to a file 140 | with open(output_filename, "wb") as file: 141 | file.write(audio_bytes) 142 | print(f"File '{output_filename}' has been generated successfully.") 143 | 144 | # play the audio if specified 145 | if (play_sound) : 146 | playsound(output_filename) 147 | 148 | # break after processing a valid endpoint 149 | break 150 | 151 | # define a function to split the text into chunks of maximum 300 characters or less 152 | def _split_text(text: str) -> list[str]: 153 | # empty list to store merged chunks 154 | merged_chunks: list[str] = [] 155 | 156 | # split the text into chunks based on punctuation marks 157 | # change the regex [.,!?:;-] to add more seperation points 158 | seperated_chunks: list[str] = re.findall(r'.*?[.,!?:;-]|.+', text) 159 | 160 | # iterate through the chunks to check for their lengths 161 | for i, chunk in enumerate(seperated_chunks): 162 | if len(chunk) > 300: 163 | # Split chunk further into smaller parts 164 | seperated_chunks[i:i+1] = re.findall(r'.*?[ ]|.+', chunk) 165 | 166 | # initialize an empty string to hold the merged chunk 167 | merged_chunk: str = "" 168 | 169 | for seperated_chunk in seperated_chunks: 170 | # check if adding the current chunk would exceed the limit of 300 characters 171 | if len(merged_chunk) + len(seperated_chunk) <= 300: 172 | merged_chunk += seperated_chunk 173 | else: 174 | # start a new merged chunk 175 | merged_chunks.append(merged_chunk) 176 | merged_chunk = seperated_chunk 177 | 178 | # append the last merged chunk to the list 179 | merged_chunks.append(merged_chunk) 180 | return merged_chunks 181 | -------------------------------------------------------------------------------- /create_story.py: -------------------------------------------------------------------------------- 1 | from langchain_openai import ChatOpenAI 2 | from langchain_core.prompts import PromptTemplate 3 | from langchain_core.prompts import FewShotPromptTemplate 4 | import random 5 | import requests 6 | import io 7 | from PIL import Image 8 | import os 9 | import datetime 10 | from tiktokvoice import tts 11 | from moviepy.editor import ImageSequenceClip, concatenate_videoclips, AudioFileClip, CompositeVideoClip 12 | import os 13 | from pydub import AudioSegment 14 | import freesound 15 | from gradio_client import Client 16 | 17 | # import from the .env file the OPEN_AI_KEY 18 | from dotenv import load_dotenv 19 | load_dotenv() 20 | 21 | api_key = os.getenv("OPENAI_API_KEY") 22 | hf_key = os.getenv("HF_token") 23 | 24 | 25 | def generate_script(temperature, keyword): 26 | llm = ChatOpenAI(model="gpt-4o-mini",api_key=api_key, temperature=temperature) 27 | initial_prompt = f"Write a suspenseful short story with fictional characters talking about {keyword} and give it a title. The story should be broken down into a 30-second timeline with time codes at every 5-second interval (except for the last part). Focus on building tension gradually, starting with a mysterious setting, followed by an unsettling event, and culminating in a cliffhanger. The tone should be eerie and the pacing should accelerate towards the end. Use vivid sensory details to enhance the suspense.\nHere's a 30-second short suspenseful story:" 28 | 29 | 30 | example_prompt = PromptTemplate.from_template("{prompt}\n{completion}") 31 | example = [ 32 | {"prompt":initial_prompt, 33 | "completion":""" 34 | **"The Dark Stairway"** 35 | 36 | [0s-5s] 37 | Dr. Douglass finds himself standing in front of an old, creepy mansion with tall trees surrounding it. The wind is howling, and the creaking of the branches makes him feel uneasy. He takes a deep breath and push open the creaky door. 38 | 39 | [5s-10s] 40 | As he steps inside, the door slams shut behind him, and he hears the sound of locks clicking into place. He's plunged into darkness, except for a faint light flickering from upstairs. His heart starts racing as he realizes he's trapped. 41 | 42 | [10s-15s] 43 | The doctor slowly makes his way up the stairs, his eyes fixed on the light source. As he reaches the top, he sees a figure standing at the far end of the landing, illuminated by the dim glow of a single bulb. But as he takes another step forward... 44 | 45 | [15s-20s] 46 | ...the figure suddenly turns to face him. He freezes in terror, and his breath catches in his throat. It's a woman with a twisted grin on her face, her eyes black as coal. She takes a slow step closer, her voice barely above a whisper: "Welcome home..." 47 | 48 | [20s-30s] 49 | His heart is pounding like a drum, and he tries to take a step back, but his feet feel rooted to the spot. The woman's grin grows wider, and she raises her hand, as if reaching for something... 50 | """} 51 | ] 52 | 53 | 54 | 55 | prompt = FewShotPromptTemplate( 56 | examples=example, 57 | example_prompt=example_prompt, 58 | suffix="{input}", 59 | input_variables=["input"], 60 | ) 61 | """Generates a script using the model.""" 62 | 63 | message = llm.invoke(prompt.invoke({"input": initial_prompt}).to_string()) 64 | return message 65 | 66 | def parse_script(script): 67 | """Parses the script and extracts the title, timeline, and content.""" 68 | title = script.split("**")[1] 69 | # we extract the text of each event in the timeline without the time codes 70 | timeline = [event.split("\n")[1] for event in script.split("[")[1:]] 71 | 72 | 73 | return title, timeline 74 | 75 | def generate_image_generation_prompt(timeline_event): 76 | llm = ChatOpenAI(model="gpt-4o-mini",api_key=api_key) 77 | """Generates an image generation prompt based on the timeline event.""" 78 | initial_prompt = f"Generate one sentence that summarizes the following event, the sentence will be used as a caption to describe an image related to the event, avoid talking about texts and discussions, if the event is a discussion try to describe the persons talking as best as you can:\n{timeline_event}\nHere is the description sentence:" 79 | example_prompt = PromptTemplate.from_template("Generate one sentence that summarizes the following event, the sentence will be used as a caption to describe an image related to the event:\n{example_event}\nHere is the description sentence: {completion}") 80 | example = [ 81 | {"example_event":"You find yourself standing in front of an old, creepy mansion with tall trees surrounding it. The wind is howling, and the creaking of the branches makes you feel uneasy. You take a deep breath and push open the creaky door.", 82 | "completion":"A man standing in front of a creepy mansion with tall trees surrounding it and a creaky door."}, 83 | {"example_event":"As you step inside, the door slams shut behind you, and you hear the sound of locks clicking into place. You're plunged into darkness, except for a faint light flickering from upstairs. Your heart starts racing as you realize you're trapped.", 84 | "completion": "A man entering a dark room with a faint light flickering from upstairs."}, 85 | {"example_event":"You slowly make your way up the stairs, your eyes fixed on the light source. As you reach the top, you see a figure standing at the far end of the landing, illuminated by the dim glow of a single bulb. But as you take another step forward...", 86 | "completion":"A very dark room with a dark figure standing at the end of the landing."}, 87 | {"example_event":"John told Mary, 'We need to hurry up in order to not be late for the meeting.' Mary replied, 'I know, but I can't find my keys anywhere.' John said, 'Don't worry, we'll find them together.'", 88 | "completion":"a man discussing with a worried woman about finding keys."}, 89 | ] 90 | prompt = FewShotPromptTemplate( 91 | examples=example, 92 | example_prompt=example_prompt, 93 | suffix="{input}", 94 | input_variables=["input"], 95 | ) 96 | 97 | message = llm.invoke(prompt.invoke({"input": initial_prompt}).to_string()) 98 | 99 | return message 100 | 101 | def generate_image(image_prompt, title): 102 | """Generates an image based on the image prompt.""" 103 | API_URL = "https://api-inference.huggingface.co/models/XLabs-AI/flux-RealismLora" 104 | headers = {"Authorization": f"Bearer {hf_key}"} 105 | payload = { 106 | "inputs": image_prompt, 107 | } 108 | 109 | 110 | response = requests.post(API_URL, headers=headers, json=payload) 111 | image_bytes = response.content 112 | # You can access the image with PIL.Image for example 113 | 114 | image = Image.open(io.BytesIO(image_bytes)) 115 | # create a directory in the current directory named as the variable title 116 | 117 | 118 | now = datetime.datetime.now() 119 | # save the image in the directory 120 | image.save(f"./{title}/{now}.jpg") 121 | return None 122 | 123 | 124 | 125 | voices = ['en_us_001', # English US - Female (Int. 1) 126 | 'en_us_002', # English US - Female (Int. 2) 127 | 'en_us_006', # English US - Male 1 128 | 'en_us_007', # English US - Male 2 129 | 'en_us_009', # English US - Male 3 130 | 'en_us_010', ] 131 | 132 | voice = random.choice(voices) 133 | 134 | trendy_keywords = ["a mysterious witch that poisons children","a weird politician that kidnaps women","a dangerous monster that eats humans","a haunted house that traps people","a cursed doll that kills its owners","a creepy clown that terrorizes a town","a ghost ship that appears in the night","a cursed forest that drives people insane","a haunted hotel that traps its guests", "a possessed door that leads to another dimension","a weird adventure to another realm through a trap","a haunted classroom that makes everyone crazy","a woman that goes to dates and kidnaps the men she meet", ""] 135 | keyword = random.choice(trendy_keywords) 136 | temperature = random.uniform(0.5, 1.0) 137 | print(f"Generating a suspenseful short story about {keyword} with a temperature of {temperature}...") 138 | script = generate_script(temperature, keyword) 139 | print("Script generated successfully!") 140 | title, timeline = parse_script(script.content) 141 | title = title.replace('"',"") 142 | os.makedirs(title, exist_ok=True) 143 | # save the title and the timeline in a text file 144 | with open(f"{title}/story.txt", "w") as f: 145 | f.write(title + "\n\n") 146 | for event in timeline: 147 | f.write(event + "\n") 148 | now = datetime.datetime.now() 149 | i=0 150 | for event in timeline: 151 | image_prompt = generate_image_generation_prompt(event) 152 | generate_image(image_prompt.content,title) 153 | tts(event.replace("..."," "),voice,f"{title}/{i}.mp3",False) 154 | print("Image and voice generated successfully!") 155 | i+=1 156 | 157 | print("All images and voices generated successfully!") 158 | 159 | 160 | 161 | # Define the directory containing the images 162 | image_directory = f'./{title}' 163 | 164 | # Get the list of images in the directory 165 | image_files = sorted([os.path.join(image_directory, img) for img in os.listdir(image_directory) if img.endswith(('.png', '.jpg', '.jpeg'))]) 166 | 167 | # Get the list of voice files in the directory 168 | voice_files = sorted([os.path.join(image_directory, img) for img in os.listdir(image_directory) if img.endswith('.mp3')]) 169 | 170 | 171 | # Define a list of durations for each voice file 172 | durations = [] 173 | 174 | for voice_file in voice_files: 175 | voice = AudioSegment.from_file(voice_file) 176 | duration = len(voice) / 1000 # Convert from milliseconds to seconds 177 | durations.append(duration) 178 | 179 | # Create a list of video clips, each corresponding to an image with its respective duration and audio 180 | clips = [] 181 | for image_file, voice_file, duration in zip(image_files, voice_files, durations): 182 | # Create a video clip from the image 183 | clip = ImageSequenceClip([image_file], durations=[duration]) 184 | 185 | # Add the corresponding audio file 186 | audio = AudioFileClip(voice_file) 187 | clip = clip.set_audio(audio) 188 | 189 | clips.append(clip) 190 | 191 | # Concatenate all the clips into a single video 192 | final_clip = concatenate_videoclips(clips, method="compose") 193 | 194 | 195 | # Write the video file 196 | output_file = f'{title}.mp4' 197 | final_clip.write_videofile(output_file, codec='libx264', fps=24) 198 | --------------------------------------------------------------------------------