Spaces:
Sleeping
Sleeping
Add rest of files
Browse files- cache_utils.py +47 -0
- config/agents.yaml +23 -0
- config/tasks.yaml +19 -0
- crew.py +97 -0
- pyproject.toml +26 -0
- requirements.txt +7 -0
- tools/__init__.py +0 -0
- tools/__pycache__/__init__.cpython-310.pyc +0 -0
- tools/__pycache__/custom_tool.cpython-310.pyc +0 -0
- tools/__pycache__/video_generation_tool.cpython-310.pyc +0 -0
- tools/__pycache__/video_tool.cpython-310.pyc +0 -0
- tools/custom_tool.py +19 -0
- tools/video_generation_tool.py +68 -0
- video_gen_new.py +674 -0
cache_utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Dict, Optional, Tuple
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def get_cached_content(app_name: str) -> Optional[Tuple[str, Dict]]:
|
| 6 |
+
"""
|
| 7 |
+
Get cached video path and script for predefined apps
|
| 8 |
+
"""
|
| 9 |
+
# Use relative paths for outputs
|
| 10 |
+
video_path = os.path.join('outputs', 'sizzle_reel', 'final')
|
| 11 |
+
script_path = os.path.join('outputs', 'sizzle_reel', 'scripts')
|
| 12 |
+
|
| 13 |
+
# Create directories if they don't exist
|
| 14 |
+
os.makedirs(video_path, exist_ok=True)
|
| 15 |
+
os.makedirs(script_path, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
cached_videos = {
|
| 18 |
+
"BloomBuddy": "BloomBuddy_sizzle_reel_HQ.mp4",
|
| 19 |
+
"FitFlow AI": "FitFlowAI_sizzle_reel_HQ.mp4",
|
| 20 |
+
"LingoPal": "LingoPal_sizzle_reel_HQ.mp4",
|
| 21 |
+
"MindMate": "MindMate_sizzle_reel_HQ.mp4",
|
| 22 |
+
"EcoCart": "EcoCart_sizzle_reel_HQ.mp4",
|
| 23 |
+
"ChefSync": "ChefSync_sizzle_reel_HQ.mp4",
|
| 24 |
+
"WanderWise": "WanderWise_sizzle_reel_HQ.mp4",
|
| 25 |
+
"SkillShare+": "SkillShare__sizzle_reel_HQ.mp4"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if app_name not in cached_videos:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
# Remove spaces from app name for file paths
|
| 32 |
+
sanitized_name = app_name.strip().replace(" ", "")
|
| 33 |
+
video_file = os.path.join(video_path, cached_videos[app_name.strip()])
|
| 34 |
+
script_file = os.path.join(script_path, f"{sanitized_name.lower()}.json")
|
| 35 |
+
|
| 36 |
+
# Add debug print to check paths
|
| 37 |
+
print(f"Looking for video at: {video_file}")
|
| 38 |
+
print(f"Looking for script at: {script_file}")
|
| 39 |
+
|
| 40 |
+
if not os.path.exists(video_file) or not os.path.exists(script_file):
|
| 41 |
+
print("Script or video not found... Try again.")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
with open(script_file, 'r') as f:
|
| 45 |
+
script = f.read()
|
| 46 |
+
|
| 47 |
+
return video_file, script
|
config/agents.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
hero_research_specialist:
|
| 2 |
+
role: "Hero Research Specialist"
|
| 3 |
+
goal: "Identify the hero user, use case, and journey"
|
| 4 |
+
backstory: >
|
| 5 |
+
Context: You are part of a generative crew of assistants integrated into a 'Sizzle Reel Script Generation System'
|
| 6 |
+
The objective of this crew is to generate an engaging script for sizzle reel generation.
|
| 7 |
+
|
| 8 |
+
Your persona: An experienced user experience researcher specializing in understanding user personas and their journeys.
|
| 9 |
+
|
| 10 |
+
content_strategist:
|
| 11 |
+
role: "Content Strategy Finalizer"
|
| 12 |
+
goal: "Refine and finalize the content strategy for the sizzle reel"
|
| 13 |
+
backstory: >
|
| 14 |
+
An expert in aligning content with brand messaging and audience expectations
|
| 15 |
+
|
| 16 |
+
narrative_scriptwriter:
|
| 17 |
+
role: "Narrative Scriptwriter"
|
| 18 |
+
goal: "Generate the content plan and script"
|
| 19 |
+
backstory: >
|
| 20 |
+
Context: You are part of a generative crew of assistants integrated into a 'Sizzle Reel Script Generation System'.
|
| 21 |
+
The objective of this crew is to generate an engaging script for sizzle reel generation.
|
| 22 |
+
|
| 23 |
+
Your persona: A creative scriptwriter skilled in crafting engaging narratives for multimedia presentations.
|
config/tasks.yaml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
hero_research_task:
|
| 2 |
+
description: >
|
| 3 |
+
Identify the hero user and their primary use case. Understand why they would use the app and what problem it solves for them.
|
| 4 |
+
Identify the hero-user, hero-usecase, and hero user journey from the app name '{app_name}',
|
| 5 |
+
customer idea '{customer_idea}'. Constraint - Maximum 5-7 steps in the user journey
|
| 6 |
+
expected_output: "Outline the steps the hero user takes in their journey within the app, from start to finish."
|
| 7 |
+
agent: hero_research_specialist
|
| 8 |
+
|
| 9 |
+
content_plan_task:
|
| 10 |
+
description: >
|
| 11 |
+
Generate a detailed content plan for the sizzle reel for App '{app_name}', idea: '{app_idea}'.
|
| 12 |
+
expected_output: "A detailed content plan for the sizzle reel."
|
| 13 |
+
agent: content_strategist
|
| 14 |
+
|
| 15 |
+
script_generation_task:
|
| 16 |
+
description: >
|
| 17 |
+
Develop the final script for the sizzle reel for app: '{app_name}', including engaging narrations and screen actions for each step in the hero's journey.
|
| 18 |
+
expected_output: "A detailed script with narrations and screen actions."
|
| 19 |
+
agent: narrative_scriptwriter
|
crew.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from crewai import LLM, Agent, Crew, Process, Task
|
| 4 |
+
from crewai.project import CrewBase, agent, crew, task
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from models import *
|
| 7 |
+
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
@CrewBase
|
| 11 |
+
class SizzleReelCrew:
|
| 12 |
+
"""Sizzle Reel Script Generation Crew"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, inputs, *args, **kwargs):
|
| 15 |
+
super().__init__(*args, **kwargs)
|
| 16 |
+
# Get OpenAI API key from environment
|
| 17 |
+
openai_api_key = os.getenv('OPENAI_API_KEY')
|
| 18 |
+
if not openai_api_key:
|
| 19 |
+
raise ValueError("OpenAI API Key is not set. Please set the OPENAI_API_KEY environment variable.")
|
| 20 |
+
|
| 21 |
+
# Configure LLM with explicit provider
|
| 22 |
+
self.llm = LLM(
|
| 23 |
+
model="openai/gpt-4o-mini",
|
| 24 |
+
temperature=0.7,
|
| 25 |
+
api_key=openai_api_key
|
| 26 |
+
)
|
| 27 |
+
self.inputs = inputs
|
| 28 |
+
|
| 29 |
+
# self.llm = LLM(
|
| 30 |
+
# model="gemini/gemini-2.0-flash-lite",
|
| 31 |
+
# temperature=0.7,
|
| 32 |
+
# api_key=os.getenv('GEMINI_API_KEY')
|
| 33 |
+
# )
|
| 34 |
+
|
| 35 |
+
@agent
|
| 36 |
+
def hero_research_specialist(self) -> Agent:
|
| 37 |
+
return Agent(
|
| 38 |
+
config=self.agents_config['hero_research_specialist'],
|
| 39 |
+
verbose=True,
|
| 40 |
+
llm=self.llm # Use the configured LLM
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
@agent
|
| 44 |
+
def content_strategist(self) -> Agent:
|
| 45 |
+
return Agent(
|
| 46 |
+
config=self.agents_config['content_strategist'],
|
| 47 |
+
verbose=True,
|
| 48 |
+
llm=self.llm # Use the configured LLM
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@agent
|
| 52 |
+
def narrative_scriptwriter(self) -> Agent:
|
| 53 |
+
return Agent(
|
| 54 |
+
config=self.agents_config['narrative_scriptwriter'],
|
| 55 |
+
verbose=True,
|
| 56 |
+
llm=self.llm # Use the configured LLM
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
@task
|
| 60 |
+
def hero_research_task(self) -> Task:
|
| 61 |
+
return Task(
|
| 62 |
+
description=f"""Identify the hero user and their primary use case. Understand why they would use the app and what problem it solves for them.
|
| 63 |
+
Identify the hero-user, hero-usecase, and hero user journey from app name {self.inputs['app_name']}.
|
| 64 |
+
App Description: {self.inputs['customer_idea']}""",
|
| 65 |
+
expected_output="""Outline the steps the hero user takes in their journey within the app, from start to finish.""",
|
| 66 |
+
agent=self.hero_research_specialist(),
|
| 67 |
+
output_pydantic=HeroResearchOutput
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
@task
|
| 71 |
+
def content_plan_task(self) -> Task:
|
| 72 |
+
return Task(
|
| 73 |
+
description=f"""Generate a detailed content plan for the sizzle reel for App {self.inputs['app_name']}""",
|
| 74 |
+
expected_output="""A detailed content plan for the sizzle reel""",
|
| 75 |
+
agent=self.content_strategist(),
|
| 76 |
+
context=[self.hero_research_task()]
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
@task
|
| 80 |
+
def script_generation_task(self) -> Task:
|
| 81 |
+
return Task(
|
| 82 |
+
description=f"""Develop the final script for the sizzle reel for app: '{self.inputs['app_name']}', including engaging narrations and screen actions for each step in the hero's journey.""",
|
| 83 |
+
expected_output="""A detailed script with narrations and screen actions.""",
|
| 84 |
+
agent=self.narrative_scriptwriter(),
|
| 85 |
+
output_pydantic=ScriptOutput,
|
| 86 |
+
context=[self.hero_research_task(), self.content_plan_task()]
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
@crew
|
| 90 |
+
def crew(self) -> Crew:
|
| 91 |
+
return Crew(
|
| 92 |
+
agents=self.agents,
|
| 93 |
+
tasks=self.tasks,
|
| 94 |
+
process=Process.sequential,
|
| 95 |
+
verbose=True,
|
| 96 |
+
llm=self.llm # Use the configured LLM for the entire crew
|
| 97 |
+
)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "script_gen"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "script-gen using crewAI"
|
| 5 |
+
authors = [
|
| 6 |
+
{ name = "Your Name", email = "you@example.com" },
|
| 7 |
+
]
|
| 8 |
+
requires-python = ">=3.10,<3.13"
|
| 9 |
+
dependencies = [
|
| 10 |
+
"crewai[tools]>=0.98.0,<1.0.0",
|
| 11 |
+
"deepgram-sdk>=3.9.0",
|
| 12 |
+
"moviepy>=2.1.2",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
[project.scripts]
|
| 16 |
+
script_gen = "script_gen.main:run"
|
| 17 |
+
run_crew = "script_gen.main:run"
|
| 18 |
+
train = "script_gen.main:train"
|
| 19 |
+
replay = "script_gen.main:replay"
|
| 20 |
+
test = "script_gen.main:test"
|
| 21 |
+
|
| 22 |
+
[build-system]
|
| 23 |
+
requires = [
|
| 24 |
+
"hatchling",
|
| 25 |
+
]
|
| 26 |
+
build-backend = "hatchling.build"
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
crewai
|
| 2 |
+
deepgram-sdk
|
| 3 |
+
crewai_tools
|
| 4 |
+
crewai['tools']
|
| 5 |
+
moviepy
|
| 6 |
+
python-dotenv
|
| 7 |
+
pydantic
|
tools/__init__.py
ADDED
|
File without changes
|
tools/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (182 Bytes). View file
|
|
|
tools/__pycache__/custom_tool.cpython-310.pyc
ADDED
|
Binary file (1.23 kB). View file
|
|
|
tools/__pycache__/video_generation_tool.cpython-310.pyc
ADDED
|
Binary file (2.11 kB). View file
|
|
|
tools/__pycache__/video_tool.cpython-310.pyc
ADDED
|
Binary file (1.39 kB). View file
|
|
|
tools/custom_tool.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from crewai.tools import BaseTool
|
| 2 |
+
from typing import Type
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MyCustomToolInput(BaseModel):
|
| 7 |
+
"""Input schema for MyCustomTool."""
|
| 8 |
+
argument: str = Field(..., description="Description of the argument.")
|
| 9 |
+
|
| 10 |
+
class MyCustomTool(BaseTool):
|
| 11 |
+
name: str = "Name of my tool"
|
| 12 |
+
description: str = (
|
| 13 |
+
"Clear description for what this tool is useful for, your agent will need this information to use it."
|
| 14 |
+
)
|
| 15 |
+
args_schema: Type[BaseModel] = MyCustomToolInput
|
| 16 |
+
|
| 17 |
+
def _run(self, argument: str) -> str:
|
| 18 |
+
# Implementation goes here
|
| 19 |
+
return "this is an example of a tool output, ignore it and move along."
|
tools/video_generation_tool.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /Users/amogh/Documents/amogh/projects/crew-ai/script_gen/src/script_gen/tools/video_tool.py
|
| 2 |
+
from crewai.tools import BaseTool
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
# Import the video generator
|
| 7 |
+
from script_gen.video_gen_new import SizzleReelVideoGenerator
|
| 8 |
+
|
| 9 |
+
class VideoGenerationTool(BaseTool):
|
| 10 |
+
"""
|
| 11 |
+
Custom tool for generating sizzle reel videos
|
| 12 |
+
"""
|
| 13 |
+
name: str = "Video Generation Tool"
|
| 14 |
+
description: str = (
|
| 15 |
+
"Generates a sizzle reel video from a prepared script. "
|
| 16 |
+
"Takes a JSON script and an app name, and produces a video path."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
super().__init__()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _run(
|
| 24 |
+
self,
|
| 25 |
+
script_json: Dict[str, Any],
|
| 26 |
+
app_name: str = "CleverApp"
|
| 27 |
+
) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Generate a sizzle reel video from the provided script
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
script_json (Dict): The script for the sizzle reel
|
| 33 |
+
app_name (str, optional): Name of the app. Defaults to "CleverApp".
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
str: Path to the generated video
|
| 37 |
+
|
| 38 |
+
Raises:
|
| 39 |
+
ValueError: If video generation fails
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
# Log the start of video generation
|
| 43 |
+
logging.info(f"Starting video generation for {app_name}")
|
| 44 |
+
|
| 45 |
+
self.video_generator = SizzleReelVideoGenerator()
|
| 46 |
+
|
| 47 |
+
# Generate the video
|
| 48 |
+
video_path = self.video_generator.generate_sizzle_reel(
|
| 49 |
+
script_json=script_json,
|
| 50 |
+
app_name=app_name
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Validate video generation
|
| 54 |
+
if not video_path:
|
| 55 |
+
raise ValueError("Video generation failed: No video path returned")
|
| 56 |
+
|
| 57 |
+
# Log successful generation
|
| 58 |
+
logging.info(f"Video generated successfully: {video_path}")
|
| 59 |
+
|
| 60 |
+
return video_path
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
# Log any errors during video generation
|
| 64 |
+
logging.error(f"Error in video generation: {e}")
|
| 65 |
+
raise ValueError(f"Video generation failed: {str(e)}")
|
| 66 |
+
|
| 67 |
+
# Create a single instance of the tool
|
| 68 |
+
video_tool = VideoGenerationTool()
|
video_gen_new.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import requests
|
| 8 |
+
from deepgram import DeepgramClient, SpeakOptions
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from moviepy import (AudioFileClip, CompositeVideoClip, TextClip,
|
| 11 |
+
VideoFileClip, concatenate_videoclips)
|
| 12 |
+
|
| 13 |
+
# Create logs directory if it doesn't exist
|
| 14 |
+
os.makedirs('logs', exist_ok=True)
|
| 15 |
+
|
| 16 |
+
# Configure logging
|
| 17 |
+
logging.basicConfig(level=logging.INFO,
|
| 18 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 19 |
+
handlers=[
|
| 20 |
+
logging.FileHandler('logs/video_generation.log'),
|
| 21 |
+
logging.StreamHandler()
|
| 22 |
+
])
|
| 23 |
+
|
| 24 |
+
class SizzleReelVideoGenerator:
|
| 25 |
+
def __init__(self, min_clip_duration=3, max_clip_duration=10):
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
# Collect Pexels API keys
|
| 29 |
+
self.pexels_api_keys = [
|
| 30 |
+
os.getenv('PEXELS_API_KEY'),
|
| 31 |
+
os.getenv('PEXELS_API_KEY_2'),
|
| 32 |
+
os.getenv('PEXELS_API_KEY_3')
|
| 33 |
+
]
|
| 34 |
+
self.pexels_api_keys = [key for key in self.pexels_api_keys if key]
|
| 35 |
+
|
| 36 |
+
# Initialize ElevenLabs client with the single key
|
| 37 |
+
self.deepgram_client = DeepgramClient(os.getenv('DEEPGRAM_API_KEY'))
|
| 38 |
+
|
| 39 |
+
# Standard video settings
|
| 40 |
+
self.target_width = 800
|
| 41 |
+
self.target_height = 600
|
| 42 |
+
self.target_aspect_ratio = 16/9
|
| 43 |
+
|
| 44 |
+
# Clip duration constraints
|
| 45 |
+
self.min_clip_duration = min_clip_duration
|
| 46 |
+
self.max_clip_duration = max_clip_duration
|
| 47 |
+
|
| 48 |
+
# Use relative paths for outputs
|
| 49 |
+
self.base_output_dir = os.path.join('outputs', 'sizzle_reel')
|
| 50 |
+
self.audio_dir = os.path.join(self.base_output_dir, 'audio')
|
| 51 |
+
self.video_dir = os.path.join(self.base_output_dir, 'videos')
|
| 52 |
+
self.final_dir = os.path.join(self.base_output_dir, 'final')
|
| 53 |
+
|
| 54 |
+
# Create all necessary directories
|
| 55 |
+
os.makedirs('logs', exist_ok=True)
|
| 56 |
+
for dir_path in [self.audio_dir, self.video_dir, self.final_dir]:
|
| 57 |
+
os.makedirs(dir_path, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
def _rotate_pexels_key(self):
|
| 60 |
+
"""Rotate Pexels API keys if one fails"""
|
| 61 |
+
if len(self.pexels_api_keys) > 1:
|
| 62 |
+
# Move the first key to the end
|
| 63 |
+
self.pexels_api_keys.append(self.pexels_api_keys.pop(0))
|
| 64 |
+
return self.pexels_api_keys[0]
|
| 65 |
+
|
| 66 |
+
def fetch_pexels_video(self, query, max_retries=3):
|
| 67 |
+
# Create a copy of the API keys to avoid modifying the original list
|
| 68 |
+
current_keys = self.pexels_api_keys.copy()
|
| 69 |
+
|
| 70 |
+
for attempt in range(max_retries):
|
| 71 |
+
# If no keys left, break the loop
|
| 72 |
+
if not current_keys:
|
| 73 |
+
logging.error(f"No more Pexels API keys available for query: {query}")
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
# Use the first available key
|
| 78 |
+
current_key = current_keys[0]
|
| 79 |
+
|
| 80 |
+
url = "https://api.pexels.com/videos/search"
|
| 81 |
+
headers = {"Authorization": current_key}
|
| 82 |
+
params = {
|
| 83 |
+
"query": query,
|
| 84 |
+
"per_page": 5, # Increase to have more fallback options
|
| 85 |
+
"page": 1
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
response = requests.get(url, headers=headers, params=params)
|
| 89 |
+
|
| 90 |
+
# Check for rate limit or authentication error
|
| 91 |
+
if response.status_code in [401, 403]:
|
| 92 |
+
# Remove the current key and continue with the next
|
| 93 |
+
current_keys.pop(0)
|
| 94 |
+
logging.warning(f"Pexels API key failed. Trying next key. Remaining keys: {len(current_keys)}")
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
# Raise an exception for other HTTP errors
|
| 98 |
+
response.raise_for_status()
|
| 99 |
+
|
| 100 |
+
data = response.json()
|
| 101 |
+
|
| 102 |
+
if not data.get('videos'):
|
| 103 |
+
logging.warning(f"No videos found for query: {query}")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
# Try to find an SD video from the list of results
|
| 107 |
+
for video in data['videos']:
|
| 108 |
+
video_files = video['video_files']
|
| 109 |
+
sd_videos = [v for v in video_files if v['quality'] == 'sd']
|
| 110 |
+
|
| 111 |
+
if sd_videos:
|
| 112 |
+
sd_videos.sort(key=lambda x: x['size'])
|
| 113 |
+
return sd_videos[0]['link']
|
| 114 |
+
|
| 115 |
+
# If no SD videos found
|
| 116 |
+
logging.warning(f"No SD quality videos found for query: {query}")
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logging.error(f"Error fetching Pexels video (Attempt {attempt + 1}): {e}")
|
| 121 |
+
|
| 122 |
+
# Remove the current key and continue with the next
|
| 123 |
+
current_keys.pop(0)
|
| 124 |
+
logging.warning(f"Pexels API key failed. Trying next key. Remaining keys: {len(current_keys)}")
|
| 125 |
+
|
| 126 |
+
# If all attempts fail
|
| 127 |
+
logging.error(f"Failed to fetch video for query: {query} after multiple attempts")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
def _sanitize_filename(self, filename):
|
| 131 |
+
sanitized = re.sub(r'[^\w\-_\. ]', '_', filename)
|
| 132 |
+
return sanitized[:50]
|
| 133 |
+
|
| 134 |
+
# def generate_elevenlabs_voiceover(self, text, step_description, voice_id="JBFqnCBsd6RMkjVDRZzb"):
|
| 135 |
+
# audio_stream = self.elevenlabs_client.text_to_speech.convert(
|
| 136 |
+
# text=text,
|
| 137 |
+
# voice_id=voice_id,
|
| 138 |
+
# model_id="eleven_flash_v2",
|
| 139 |
+
# output_format="mp3_44100_128",
|
| 140 |
+
# voice_settings={
|
| 141 |
+
# "stability": 0.0,
|
| 142 |
+
# "similarity_boost": 1.0,
|
| 143 |
+
# "style": 0.0,
|
| 144 |
+
# "use_speaker_boost": True
|
| 145 |
+
# }
|
| 146 |
+
# )
|
| 147 |
+
|
| 148 |
+
# sanitized_step = self._sanitize_filename(step_description)
|
| 149 |
+
# audio_filename = f"{sanitized_step}_voiceover.mp3"
|
| 150 |
+
# audio_path = os.path.join(self.audio_dir, audio_filename)
|
| 151 |
+
|
| 152 |
+
# with open(audio_path, 'wb') as f:
|
| 153 |
+
# for chunk in audio_stream:
|
| 154 |
+
# if chunk:
|
| 155 |
+
# f.write(chunk)
|
| 156 |
+
|
| 157 |
+
# return audio_path
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def generate_voiceover(self, text, step_description, voice_id="aura-athena-en"):
|
| 161 |
+
try:
|
| 162 |
+
# Prepare text and filename
|
| 163 |
+
text_payload = {"text": text}
|
| 164 |
+
sanitized_step = self._sanitize_filename(step_description)
|
| 165 |
+
audio_filename = f"{sanitized_step}_voiceover.mp3"
|
| 166 |
+
audio_path = os.path.join(self.audio_dir, audio_filename)
|
| 167 |
+
|
| 168 |
+
# Configure speak options
|
| 169 |
+
options = SpeakOptions(
|
| 170 |
+
model=voice_id, # Use the specified voice, with a default
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Generate and save the audio
|
| 174 |
+
response = self.deepgram_client.speak.v("1").save(audio_path, text_payload, options)
|
| 175 |
+
|
| 176 |
+
logging.info(f"Generated voiceover for step: {step_description}")
|
| 177 |
+
return audio_path
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logging.error(f"Error generating voiceover for step: {step_description}")
|
| 181 |
+
logging.error(f"Error details: {e}")
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def download_video(self, video_url, step_description):
|
| 186 |
+
response = requests.get(video_url, stream=True)
|
| 187 |
+
sanitized_step = self._sanitize_filename(step_description)
|
| 188 |
+
video_filename = f"{sanitized_step}_video.mp4"
|
| 189 |
+
video_path = os.path.join(self.video_dir, video_filename)
|
| 190 |
+
|
| 191 |
+
with open(video_path, 'wb') as f:
|
| 192 |
+
for chunk in response.iter_content(chunk_size=8192):
|
| 193 |
+
f.write(chunk)
|
| 194 |
+
|
| 195 |
+
return video_path
|
| 196 |
+
|
| 197 |
+
def generate_step_video(self, step, query_type='narrator'):
|
| 198 |
+
# Alternate between action and narrator for video query
|
| 199 |
+
video_query = step['action'] if query_type == 'action' else step['narrator']
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
# Generate voiceover
|
| 203 |
+
audio_path = self.generate_voiceover(
|
| 204 |
+
step['narrator'],
|
| 205 |
+
step['step_description']
|
| 206 |
+
)
|
| 207 |
+
logging.info(f"Generated voiceover for step: {step['step_description']}")
|
| 208 |
+
|
| 209 |
+
# Fetch and download video
|
| 210 |
+
video_url = self.fetch_pexels_video(video_query)
|
| 211 |
+
video_path = self.download_video(
|
| 212 |
+
video_url,
|
| 213 |
+
step['step_description']
|
| 214 |
+
)
|
| 215 |
+
logging.info(f"Downloaded video for query: {video_query}")
|
| 216 |
+
|
| 217 |
+
# Get audio duration
|
| 218 |
+
with AudioFileClip(audio_path) as audio_clip:
|
| 219 |
+
audio_duration = audio_clip.duration
|
| 220 |
+
|
| 221 |
+
return {
|
| 222 |
+
'audio_path': audio_path,
|
| 223 |
+
'video_path': video_path,
|
| 224 |
+
'step_description': step['step_description'],
|
| 225 |
+
'narrator': step['narrator'],
|
| 226 |
+
'duration': audio_duration
|
| 227 |
+
}
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logging.error(f"Error generating video for step: {step['step_description']}")
|
| 230 |
+
logging.error(f"Error details: {e}")
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
def add_captions_to_video(self, video_clip, narrator_text, audio_duration):
|
| 234 |
+
# Create a TextClip for the captions
|
| 235 |
+
caption = TextClip(
|
| 236 |
+
text=narrator_text,
|
| 237 |
+
font='Arial',
|
| 238 |
+
color='white',
|
| 239 |
+
stroke_color='black',
|
| 240 |
+
stroke_width=1,
|
| 241 |
+
method='caption',
|
| 242 |
+
size=(video_clip.w * 0.8, 50), # Narrower width, shorter height
|
| 243 |
+
bg_color='rgba(0,0,0,0.5)', # Semi-transparent background
|
| 244 |
+
text_align='center',
|
| 245 |
+
horizontal_align='center',
|
| 246 |
+
vertical_align='bottom'
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Position the caption at the bottom of the video
|
| 250 |
+
caption = caption.with_position(('center', 'bottom'))
|
| 251 |
+
|
| 252 |
+
# Set the duration to match audio
|
| 253 |
+
caption = caption.with_duration(audio_duration)
|
| 254 |
+
|
| 255 |
+
# Composite the video with the caption
|
| 256 |
+
return CompositeVideoClip([video_clip, caption])
|
| 257 |
+
|
| 258 |
+
def create_smooth_transition(self, clip1, clip2, transition_duration=1):
|
| 259 |
+
"""
|
| 260 |
+
Create a smooth crossfade transition between two video clips
|
| 261 |
+
"""
|
| 262 |
+
# Extract the underlying video clip if it's a CompositeVideoClip
|
| 263 |
+
if isinstance(clip1, CompositeVideoClip):
|
| 264 |
+
clip1 = clip1.clips[0] # Assume the first clip is the base video
|
| 265 |
+
if isinstance(clip2, CompositeVideoClip):
|
| 266 |
+
clip2 = clip2.clips[0] # Assume the first clip is the base video
|
| 267 |
+
|
| 268 |
+
# Both clips should already be standardized, but verify sizes
|
| 269 |
+
assert clip1.size == (self.target_width, self.target_height), "Clip1 size mismatch"
|
| 270 |
+
assert clip2.size == (self.target_width, self.target_height), "Clip2 size mismatch"
|
| 271 |
+
|
| 272 |
+
# Ensure both clips have the same size and fps
|
| 273 |
+
if clip1.size != clip2.size:
|
| 274 |
+
clip2 = clip2.resized(clip1.size)
|
| 275 |
+
|
| 276 |
+
# Create a transition clip
|
| 277 |
+
def transition_func(t):
|
| 278 |
+
if t < transition_duration:
|
| 279 |
+
# Linear crossfade
|
| 280 |
+
alpha1 = 1 - (t / transition_duration)
|
| 281 |
+
alpha2 = t / transition_duration
|
| 282 |
+
|
| 283 |
+
# Get frames from both clips
|
| 284 |
+
frame1 = clip1.get_frame(clip1.duration - transition_duration + t)
|
| 285 |
+
frame2 = clip2.get_frame(t)
|
| 286 |
+
|
| 287 |
+
# Blend frames
|
| 288 |
+
blended_frame = (alpha1 * frame1 + alpha2 * frame2).astype(np.uint8)
|
| 289 |
+
return blended_frame
|
| 290 |
+
else:
|
| 291 |
+
# After transition, return the second clip's frame
|
| 292 |
+
return clip2.get_frame(t - transition_duration)
|
| 293 |
+
|
| 294 |
+
# Create a transition clip
|
| 295 |
+
transition_clip = VideoFileClip(
|
| 296 |
+
filename,
|
| 297 |
+
audio=False
|
| 298 |
+
).with_duration(transition_duration)
|
| 299 |
+
transition_clip.get_frame = transition_func
|
| 300 |
+
|
| 301 |
+
return transition_clip
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def process_video_clip(self, video_path, audio_path):
|
| 305 |
+
try:
|
| 306 |
+
# Load video and audio
|
| 307 |
+
video_clip = VideoFileClip(video_path)
|
| 308 |
+
audio_clip = AudioFileClip(audio_path)
|
| 309 |
+
|
| 310 |
+
# Standardize video size first
|
| 311 |
+
video_clip = self._standardize_video_size(video_clip)
|
| 312 |
+
|
| 313 |
+
# Synchronize duration with constraints
|
| 314 |
+
audio_duration = audio_clip.duration
|
| 315 |
+
video_duration = video_clip.duration
|
| 316 |
+
|
| 317 |
+
# Adjust clip duration to be within min and max constraints
|
| 318 |
+
clip_duration = max(
|
| 319 |
+
self.min_clip_duration,
|
| 320 |
+
min(audio_duration, self.max_clip_duration, video_duration)
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Safely trim video and audio to synchronized duration
|
| 324 |
+
try:
|
| 325 |
+
video_clip = video_clip.subclipped(0, min(clip_duration, video_duration))
|
| 326 |
+
except Exception as ve:
|
| 327 |
+
logging.warning(f"Error trimming video clip: {ve}. Using full video duration.")
|
| 328 |
+
video_clip = video_clip.subclipped(0, video_duration)
|
| 329 |
+
|
| 330 |
+
try:
|
| 331 |
+
audio_clip = audio_clip.subclipped(0, min(clip_duration, audio_duration))
|
| 332 |
+
except Exception as ae:
|
| 333 |
+
logging.warning(f"Error trimming audio clip: {ae}. Using full audio duration.")
|
| 334 |
+
audio_clip = audio_clip.subclipped(0, audio_duration)
|
| 335 |
+
|
| 336 |
+
# Ensure audio and video have the same duration
|
| 337 |
+
min_duration = min(video_clip.duration, audio_clip.duration)
|
| 338 |
+
video_clip = video_clip.subclipped(0, min_duration)
|
| 339 |
+
audio_clip = audio_clip.subclipped(0, min_duration)
|
| 340 |
+
|
| 341 |
+
# Attach audio to video
|
| 342 |
+
video_clip = video_clip.with_audio(audio_clip)
|
| 343 |
+
|
| 344 |
+
return video_clip
|
| 345 |
+
except Exception as e:
|
| 346 |
+
logging.error(f"Comprehensive error processing video clip: {e}")
|
| 347 |
+
|
| 348 |
+
# Additional diagnostic logging
|
| 349 |
+
try:
|
| 350 |
+
logging.error(f"Video path: {video_path}")
|
| 351 |
+
logging.error(f"Audio path: {audio_path}")
|
| 352 |
+
|
| 353 |
+
# Log file details if possible
|
| 354 |
+
import os
|
| 355 |
+
video_exists = os.path.exists(video_path)
|
| 356 |
+
audio_exists = os.path.exists(audio_path)
|
| 357 |
+
|
| 358 |
+
logging.error(f"Video file exists: {video_exists}")
|
| 359 |
+
logging.error(f"Audio file exists: {audio_exists}")
|
| 360 |
+
|
| 361 |
+
if video_exists:
|
| 362 |
+
video_clip = VideoFileClip(video_path)
|
| 363 |
+
logging.error(f"Video duration: {video_clip.duration}")
|
| 364 |
+
|
| 365 |
+
if audio_exists:
|
| 366 |
+
audio_clip = AudioFileClip(audio_path)
|
| 367 |
+
logging.error(f"Audio duration: {audio_clip.duration}")
|
| 368 |
+
except Exception as diag_error:
|
| 369 |
+
logging.error(f"Additional diagnostic error: {diag_error}")
|
| 370 |
+
|
| 371 |
+
return None
|
| 372 |
+
|
| 373 |
+
def add_captions_to_video(self, video_clip, narrator_text, audio_duration):
|
| 374 |
+
# Create a TextClip for the captions
|
| 375 |
+
caption = TextClip(
|
| 376 |
+
text=narrator_text,
|
| 377 |
+
font='Arial',
|
| 378 |
+
color='white',
|
| 379 |
+
stroke_color='black',
|
| 380 |
+
stroke_width=2,
|
| 381 |
+
method='caption',
|
| 382 |
+
size=(video_clip.w, 100),
|
| 383 |
+
bg_color=None,
|
| 384 |
+
text_align='center',
|
| 385 |
+
horizontal_align='center',
|
| 386 |
+
vertical_align='center'
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Position the caption at the bottom of the video
|
| 390 |
+
caption = caption.with_position(('center', 'bottom'))
|
| 391 |
+
|
| 392 |
+
# Set the duration to match audio
|
| 393 |
+
caption = caption.with_duration(audio_duration)
|
| 394 |
+
|
| 395 |
+
# Composite the video with the caption
|
| 396 |
+
return CompositeVideoClip([video_clip, caption])
|
| 397 |
+
|
| 398 |
+
def create_smooth_transition(self, clip1, clip2, transition_duration=1):
|
| 399 |
+
"""
|
| 400 |
+
Create a smooth crossfade transition between two video clips
|
| 401 |
+
"""
|
| 402 |
+
# Extract the underlying video clip if it's a CompositeVideoClip
|
| 403 |
+
if isinstance(clip1, CompositeVideoClip):
|
| 404 |
+
clip1 = clip1.clips[0] # Assume the first clip is the base video
|
| 405 |
+
if isinstance(clip2, CompositeVideoClip):
|
| 406 |
+
clip2 = clip2.clips[0] # Assume the first clip is the base video
|
| 407 |
+
|
| 408 |
+
# Ensure both clips have the same size and fps
|
| 409 |
+
if clip1.size != clip2.size:
|
| 410 |
+
clip2 = clip2.resized(clip1.size)
|
| 411 |
+
|
| 412 |
+
# Create a transition clip
|
| 413 |
+
def transition_func(t):
|
| 414 |
+
if t < transition_duration:
|
| 415 |
+
# Linear crossfade
|
| 416 |
+
alpha1 = 1 - (t / transition_duration)
|
| 417 |
+
alpha2 = t / transition_duration
|
| 418 |
+
|
| 419 |
+
# Get frames from both clips
|
| 420 |
+
frame1 = clip1.get_frame(clip1.duration - transition_duration + t)
|
| 421 |
+
frame2 = clip2.get_frame(t)
|
| 422 |
+
|
| 423 |
+
# Blend frames
|
| 424 |
+
blended_frame = (alpha1 * frame1 + alpha2 * frame2).astype(np.uint8)
|
| 425 |
+
return blended_frame
|
| 426 |
+
else:
|
| 427 |
+
# After transition, return the second clip's frame
|
| 428 |
+
return clip2.get_frame(t - transition_duration)
|
| 429 |
+
|
| 430 |
+
# Create a transition clip
|
| 431 |
+
transition_clip = VideoFileClip(
|
| 432 |
+
clip1.filename if hasattr(clip1, 'filename') else '/tmp/transition.mp4',
|
| 433 |
+
audio=False
|
| 434 |
+
).with_duration(transition_duration)
|
| 435 |
+
transition_clip.get_frame = transition_func
|
| 436 |
+
|
| 437 |
+
return transition_clip
|
| 438 |
+
|
| 439 |
+
def _standardize_video_size(self, clip):
|
| 440 |
+
"""
|
| 441 |
+
Standardize video size to target resolution while maintaining aspect ratio
|
| 442 |
+
"""
|
| 443 |
+
# Get current clip size
|
| 444 |
+
w, h = clip.size
|
| 445 |
+
current_aspect_ratio = w/h
|
| 446 |
+
|
| 447 |
+
if current_aspect_ratio > self.target_aspect_ratio:
|
| 448 |
+
# Video is wider than target ratio
|
| 449 |
+
new_width = self.target_width
|
| 450 |
+
new_height = int(new_width / current_aspect_ratio)
|
| 451 |
+
else:
|
| 452 |
+
# Video is taller than target ratio
|
| 453 |
+
new_height = self.target_height
|
| 454 |
+
new_width = int(new_height * current_aspect_ratio)
|
| 455 |
+
|
| 456 |
+
# Resize video
|
| 457 |
+
resized_clip = clip.resized(width=new_width, height=new_height)
|
| 458 |
+
|
| 459 |
+
# Create black background of target size
|
| 460 |
+
from moviepy.video.VideoClip import ColorClip
|
| 461 |
+
bg = ColorClip(size=(self.target_width, self.target_height),
|
| 462 |
+
color=(0,0,0))
|
| 463 |
+
bg = bg.with_duration(clip.duration)
|
| 464 |
+
|
| 465 |
+
# Center the video on the background
|
| 466 |
+
x_offset = (self.target_width - new_width) // 2
|
| 467 |
+
y_offset = (self.target_height - new_height) // 2
|
| 468 |
+
|
| 469 |
+
final_clip = CompositeVideoClip([
|
| 470 |
+
bg,
|
| 471 |
+
resized_clip.with_position((x_offset, y_offset))
|
| 472 |
+
])
|
| 473 |
+
|
| 474 |
+
return final_clip
|
| 475 |
+
|
| 476 |
+
def generate_sizzle_reel(self, script_json, app_name="CleverApp"):
|
| 477 |
+
# Parse script based on input type
|
| 478 |
+
print("Debug", script_json)
|
| 479 |
+
|
| 480 |
+
if isinstance(script_json, str):
|
| 481 |
+
script = json.loads(script_json)
|
| 482 |
+
script_steps = script.get('sizzle_reel_script', [])
|
| 483 |
+
elif isinstance(script_json, dict) and 'sizzle_reel_script' in script_json:
|
| 484 |
+
# Handle the case where sizzle_reel_script is a CrewOutput object
|
| 485 |
+
crew_output = script_json['sizzle_reel_script']
|
| 486 |
+
if hasattr(crew_output, 'raw'):
|
| 487 |
+
# Parse the raw JSON string from CrewOutput
|
| 488 |
+
try:
|
| 489 |
+
parsed_data = json.loads(crew_output.raw)
|
| 490 |
+
script_steps = parsed_data.get('sizzle_reel_script', [])
|
| 491 |
+
except json.JSONDecodeError:
|
| 492 |
+
logging.error("Failed to parse raw CrewOutput JSON")
|
| 493 |
+
script_steps = []
|
| 494 |
+
else:
|
| 495 |
+
script_steps = crew_output
|
| 496 |
+
else:
|
| 497 |
+
script_steps = []
|
| 498 |
+
|
| 499 |
+
# Process video steps
|
| 500 |
+
processed_clips = []
|
| 501 |
+
|
| 502 |
+
# Convert to list if it's not already
|
| 503 |
+
if not isinstance(script_steps, list):
|
| 504 |
+
script_steps = [script_steps]
|
| 505 |
+
|
| 506 |
+
for i, step in enumerate(script_steps):
|
| 507 |
+
# Convert Pydantic model to dict if necessary
|
| 508 |
+
if hasattr(step, 'dict'):
|
| 509 |
+
step = step.dict()
|
| 510 |
+
# Alternate query type
|
| 511 |
+
query_type = 'narrator' if i % 2 == 0 else 'action'
|
| 512 |
+
|
| 513 |
+
# Generate step video
|
| 514 |
+
step_video = self.generate_step_video(step, query_type)
|
| 515 |
+
if not step_video:
|
| 516 |
+
logging.warning(f"Skipping step {i} due to video generation failure")
|
| 517 |
+
continue
|
| 518 |
+
|
| 519 |
+
# Process video clip
|
| 520 |
+
processed_clip = self.process_video_clip(
|
| 521 |
+
step_video['video_path'],
|
| 522 |
+
step_video['audio_path']
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
if processed_clip:
|
| 526 |
+
# Add captions
|
| 527 |
+
try:
|
| 528 |
+
captioned_clip = self.add_captions_to_video(
|
| 529 |
+
processed_clip,
|
| 530 |
+
step_video['narrator'],
|
| 531 |
+
processed_clip.duration
|
| 532 |
+
)
|
| 533 |
+
processed_clips.append(captioned_clip)
|
| 534 |
+
except Exception as caption_error:
|
| 535 |
+
logging.error(f"Error adding captions to clip {i}: {caption_error}")
|
| 536 |
+
# Fallback: use processed clip without captions
|
| 537 |
+
processed_clips.append(processed_clip)
|
| 538 |
+
|
| 539 |
+
# Check if we have any processed clips
|
| 540 |
+
if not processed_clips:
|
| 541 |
+
logging.error("No video clips could be generated")
|
| 542 |
+
return None
|
| 543 |
+
|
| 544 |
+
# Concatenate processed clips with smooth transitions
|
| 545 |
+
final_clips = []
|
| 546 |
+
for i in range(len(processed_clips) - 1):
|
| 547 |
+
final_clips.append(processed_clips[i])
|
| 548 |
+
# Add transition between clips
|
| 549 |
+
try:
|
| 550 |
+
transition = self.create_smooth_transition(
|
| 551 |
+
processed_clips[i],
|
| 552 |
+
processed_clips[i+1],
|
| 553 |
+
transition_duration=1
|
| 554 |
+
)
|
| 555 |
+
final_clips.append(transition)
|
| 556 |
+
except Exception as transition_error:
|
| 557 |
+
logging.warning(f"Could not create transition between clips {i} and {i+1}: {transition_error}")
|
| 558 |
+
|
| 559 |
+
# Add the last clip
|
| 560 |
+
final_clips.append(processed_clips[-1])
|
| 561 |
+
|
| 562 |
+
# Concatenate video clips
|
| 563 |
+
try:
|
| 564 |
+
final_video = concatenate_videoclips(final_clips, method="compose")
|
| 565 |
+
except Exception as e:
|
| 566 |
+
logging.error(f"Error concatenating video clips: {e}")
|
| 567 |
+
return None
|
| 568 |
+
|
| 569 |
+
# Sanitize app name for filename
|
| 570 |
+
sanitized_app_name = re.sub(r'[^\w\-_\. ]', '_', app_name)
|
| 571 |
+
|
| 572 |
+
# Output final video with app name
|
| 573 |
+
output_filename = f"{sanitized_app_name}_sizzle_reel.mp4"
|
| 574 |
+
output_path = os.path.join(self.final_dir, output_filename)
|
| 575 |
+
|
| 576 |
+
try:
|
| 577 |
+
final_video.write_videofile(
|
| 578 |
+
output_path,
|
| 579 |
+
codec='libx264',
|
| 580 |
+
audio_codec='aac',
|
| 581 |
+
preset='ultrafast', # Changed from 'medium' to 'ultrafast'
|
| 582 |
+
threads=12,
|
| 583 |
+
# bitrate='600k',
|
| 584 |
+
fps=24,
|
| 585 |
+
)
|
| 586 |
+
logging.info(f"Successfully generated final video: {output_path}")
|
| 587 |
+
except Exception as e:
|
| 588 |
+
logging.error(f"Error writing final video: {e}")
|
| 589 |
+
return None
|
| 590 |
+
|
| 591 |
+
# Clean up resources
|
| 592 |
+
for clip in processed_clips + final_clips + [final_video]:
|
| 593 |
+
try:
|
| 594 |
+
clip.close()
|
| 595 |
+
except Exception as close_error:
|
| 596 |
+
logging.warning(f"Error closing clip: {close_error}")
|
| 597 |
+
|
| 598 |
+
return output_path
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
# Main execution
|
| 602 |
+
if __name__ == "__main__":
|
| 603 |
+
# Your sample script here
|
| 604 |
+
sample_script = {
|
| 605 |
+
"sizzle_reel_script": [
|
| 606 |
+
{
|
| 607 |
+
"step_description": "Establishing the Problem",
|
| 608 |
+
"narrator": "In a world filled with stress and uncertainty, finding peace can feel like an uphill battle.",
|
| 609 |
+
"action": "A split-screen shows a person overwhelmed with stress on the left and another individual feeling calm and focused on the right.",
|
| 610 |
+
"features": []
|
| 611 |
+
},
|
| 612 |
+
{
|
| 613 |
+
"step_description": "The Solution",
|
| 614 |
+
"narrator": "Meet MindMate, your personal companion for mental well-being. Track your moods, find balance, and embrace tranquility.",
|
| 615 |
+
"action": "Transition to the MindMate app interface on a smartphone, showcasing its sleek design.",
|
| 616 |
+
"features": []
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"step_description": "The Onboarding Experience",
|
| 620 |
+
"narrator": "Start your journey by downloading the app and sharing your story through a simple assessment.",
|
| 621 |
+
"action": "Quick clips of a user downloading the app, creating an account, and completing the initial assessment.",
|
| 622 |
+
"features": []
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
"step_description": "Daily Engagement",
|
| 626 |
+
"narrator": "Log your emotions daily and receive gentle prompts to help you stay connected with your feelings.",
|
| 627 |
+
"action": "User logs their feelings in the mood tracker, with reminders popping up on their phone.",
|
| 628 |
+
"features": [
|
| 629 |
+
{"feature_name": "Mood Tracker", "description": "Log daily emotions."},
|
| 630 |
+
{"feature_name": "Reminders", "description": "Gentle prompts for mood check-ins."}
|
| 631 |
+
]
|
| 632 |
+
},
|
| 633 |
+
{
|
| 634 |
+
"step_description": "Tailored Support",
|
| 635 |
+
"narrator": "Experience personalized meditation sessions designed just for you, based on your unique mood patterns.",
|
| 636 |
+
"action": "User engages in a serene meditation environment, surrounded by calming visuals and soundscapes.",
|
| 637 |
+
"features": [
|
| 638 |
+
{"feature_name": "Personalized Meditations", "description": "Meditations based on mood patterns."}
|
| 639 |
+
]
|
| 640 |
+
},
|
| 641 |
+
{
|
| 642 |
+
"step_description": "Empowering Analytics",
|
| 643 |
+
"narrator": "Track your progress and discover how meditation can transform your mental health journey.",
|
| 644 |
+
"action": "User reviews analytics on their progress, smiling as they see improvements.",
|
| 645 |
+
"features": [
|
| 646 |
+
{"feature_name": "Progress Tracking", "description": "View mood trends and meditation effectiveness."}
|
| 647 |
+
]
|
| 648 |
+
},
|
| 649 |
+
{
|
| 650 |
+
"step_description": "Connection",
|
| 651 |
+
"narrator": "Join a community of individuals just like you, sharing stories, and celebrating progress together.",
|
| 652 |
+
"action": "Users share experiences and feedback within the app community, fostering connection and support.",
|
| 653 |
+
"features": [
|
| 654 |
+
{"feature_name": "Community Support", "description": "Share experiences and feedback."}
|
| 655 |
+
]
|
| 656 |
+
},
|
| 657 |
+
{
|
| 658 |
+
"step_description": "Download Now",
|
| 659 |
+
"narrator": "Ready to take the first step towards a healthier mind? Download MindMate today and start your journey to tranquility.",
|
| 660 |
+
"action": "The MindMate logo appears with app store icons for iOS and Android.",
|
| 661 |
+
"features": []
|
| 662 |
+
},
|
| 663 |
+
{
|
| 664 |
+
"step_description": "Inspirational Tone",
|
| 665 |
+
"narrator": "Your mental well-being matters. Let MindMate guide you.",
|
| 666 |
+
"action": "A serene landscape with the tagline overlaid: 'MindMate - Your Path to Peace.'",
|
| 667 |
+
"features": []
|
| 668 |
+
}
|
| 669 |
+
]
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
generator = SizzleReelVideoGenerator()
|
| 673 |
+
final_video = generator.generate_sizzle_reel(sample_script, app_name="MindMate")
|
| 674 |
+
print(f"Sizzle reel generated: {final_video}")
|