|
|
import os |
|
|
import random |
|
|
import base64 |
|
|
import tempfile |
|
|
from typing import Tuple, List, Dict, Any |
|
|
|
|
|
import gradio as gr |
|
|
import requests |
|
|
from openai import OpenAI |
|
|
from smolagents import CodeAgent, MCPClient, tool |
|
|
from huggingface_hub import InferenceClient |
|
|
from elevenlabs import ElevenLabs, VoiceSettings |
|
|
|
|
|
from quote_generator_gemini import HybridQuoteGenerator |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) |
|
|
PEXELS_API_KEY = os.getenv("PEXELS_API_KEY") |
|
|
|
|
|
|
|
|
try: |
|
|
elevenlabs_client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY")) |
|
|
except Exception as e: |
|
|
print(f"ElevenLabs init warning: {e}") |
|
|
elevenlabs_client = None |
|
|
|
|
|
|
|
|
hybrid_quote_generator = HybridQuoteGenerator( |
|
|
gemini_key=os.getenv("GEMINI_API_KEY"), |
|
|
openai_client=openai_client, |
|
|
) |
|
|
|
|
|
|
|
|
try: |
|
|
mcp_client = MCPClient("https://abidlabs-mcp-tools.hf.space") |
|
|
mcp_enabled = True |
|
|
except Exception as e: |
|
|
print(f"MCP initialization warning: {e}") |
|
|
mcp_enabled = False |
|
|
|
|
|
|
|
|
MODAL_ENDPOINT_URL = os.getenv("MODAL_ENDPOINT_URL") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_persona_instruction(persona: str) -> str: |
|
|
persona = (persona or "").lower() |
|
|
if persona == "coach": |
|
|
return ( |
|
|
"High-energy, practical, direct. Sounds like a smart, encouraging coach " |
|
|
"speaking to a friend who is capable of more." |
|
|
) |
|
|
if persona == "philosopher": |
|
|
return ( |
|
|
"Calm, reflective, almost meditative. Uses simple language to point to " |
|
|
"deeper truths without sounding mystical." |
|
|
) |
|
|
if persona == "poet": |
|
|
return ( |
|
|
"Soft, lyrical, imagery-driven. Uses metaphor but stays clear and grounded " |
|
|
"enough for TikTok viewers." |
|
|
) |
|
|
if persona == "mentor": |
|
|
return ( |
|
|
"Warm, grounded, seasoned. Feels like someone older and wiser sharing lessons " |
|
|
"learned the hard way, without lecturing." |
|
|
) |
|
|
return "Neutral, conversational, and clear." |
|
|
|
|
|
|
|
|
def get_trend_insights(niche: str) -> Dict[str, Any]: |
|
|
niche = niche or "Motivation" |
|
|
|
|
|
trends: Dict[str, Dict[str, Any]] = { |
|
|
"Motivation": { |
|
|
"label": "soft life vs discipline era", |
|
|
"summary": ( |
|
|
"Motivational content leans into 'soft life' aesthetics while still " |
|
|
"talking about discipline, systems, and quiet consistency." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Soft Life Affirmations", |
|
|
"hook": "Unlock your soft life with one small decision you can actually keep today.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Discipline Era Strategies", |
|
|
"hook": "3 ‘discipline era’ habits that don’t require waking up at 4am.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Reset Routine Hacks", |
|
|
"hook": "A 10-minute reset to get you unstuck.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Business/Entrepreneurship": { |
|
|
"label": "one-person brands & slow growth", |
|
|
"summary": ( |
|
|
"Founders are tired of hustle theatre. Trending content focuses on " |
|
|
"one-person brands, slow compounding, and honest behind-the-scenes." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Build in Public Moments", |
|
|
"hook": "Here’s the part of building nobody shows—but everyone feels.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Tiny Experiments", |
|
|
"hook": "One small experiment you can run this week instead of a 5-year plan.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Fitness": { |
|
|
"label": "sustainable glow-up", |
|
|
"summary": ( |
|
|
"Fitness trends lean toward sustainable glow-ups: walking, strength, " |
|
|
"and realistic body expectations." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Gentle Discipline Workouts", |
|
|
"hook": "A routine for the days you ‘don’t feel like it’ but still care.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Slow Glow-Up", |
|
|
"hook": "The quiet glow-up that happens when you stop quitting.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Mindfulness": { |
|
|
"label": "nervous system & soft resets", |
|
|
"summary": ( |
|
|
"Mindfulness content is shifting toward nervous system regulation, tiny " |
|
|
"resets, and practical grounding." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Micro Resets", |
|
|
"hook": "30-second resets to bring your mind back into your body.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Calm Start Routines", |
|
|
"hook": "A 3-step morning that doesn’t require journaling for 2 hours.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Stoicism": { |
|
|
"label": "quiet strength", |
|
|
"summary": ( |
|
|
"Stoic content focuses on quiet strength, emotional regulation, and not " |
|
|
"reacting to every notification, comment, or impulse." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Reaction Discipline", |
|
|
"hook": "You can’t control people—but you can control the pause before you answer.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Modern Stoic Moments", |
|
|
"hook": "3 modern situations where being stoic actually helps.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Leadership": { |
|
|
"label": "servant leadership & clarity", |
|
|
"summary": ( |
|
|
"Leadership trends highlight servant leadership, psychological safety, " |
|
|
"and simple, clear direction." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Clarity Over Charisma", |
|
|
"hook": "People don’t need a hero. They need one clear next step.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Leader as Mirror", |
|
|
"hook": "What your team hides from you tells you who you are as a leader.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
"Love & Relationships": { |
|
|
"label": "self-worth & secure attachment", |
|
|
"summary": ( |
|
|
"Relationship content leans into self-worth, boundaries, and secure " |
|
|
"attachment—not just romance but emotional safety." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Soft Boundaries", |
|
|
"hook": "Kind doesn’t mean available for everything. Here’s how to say no softly.", |
|
|
}, |
|
|
{ |
|
|
"topic": "Choosing Safe People", |
|
|
"hook": "3 green flags that matter more than butterflies.", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
} |
|
|
|
|
|
default = { |
|
|
"label": "modern glow-up & gentle discipline", |
|
|
"summary": ( |
|
|
"Short-form content leans into gentle discipline, realistic routines, " |
|
|
"and soft glow-ups instead of extreme hustle." |
|
|
), |
|
|
"topics": [ |
|
|
{ |
|
|
"topic": "Glow Up Checklist", |
|
|
"hook": "A realistic glow-up checklist you can actually follow this month.", |
|
|
} |
|
|
], |
|
|
} |
|
|
|
|
|
return trends.get(niche, default) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_caption_and_hashtags(niche: str, persona: str, trend_label: str) -> str: |
|
|
""" |
|
|
Generate a posting-ready caption, hashtags, and a tiny posting tip |
|
|
based on niche + persona + trend theme. |
|
|
|
|
|
Args: |
|
|
niche (str): Selected content niche (e.g. Motivation, Fitness). |
|
|
persona (str): Selected persona (Coach, Philosopher, etc.). |
|
|
trend_label (str): Short label describing the current trend theme. |
|
|
|
|
|
Returns: |
|
|
str: Formatted block containing caption, hashtags, and posting tip. |
|
|
""" |
|
|
persona_instruction = get_persona_instruction(persona) |
|
|
|
|
|
prompt = f""" |
|
|
Generate a social-media-ready caption and hashtags for a short vertical quote video. |
|
|
|
|
|
Niche: {niche} |
|
|
Persona / tone: {persona} ({persona_instruction}) |
|
|
Trend theme: {trend_label} |
|
|
|
|
|
Requirements: |
|
|
- CAPTION: 1–2 sentences max |
|
|
* Should sound natural, like a human writing for TikTok/Instagram |
|
|
* Should NOT repeat the quote text word-for-word |
|
|
* Can reference feelings, situation, or transformation implied by the quote |
|
|
- HASHTAGS: |
|
|
* 8–12 hashtags total |
|
|
* Mix of trending-style tags and niche / long-tail tags |
|
|
* Use lowercase, no spaces (standard hashtag conventions) |
|
|
* No banned, misleading, or spammy tags |
|
|
- POSTING TIP: |
|
|
* 1 short sentence with a practical suggestion (sound choice, posting time, or CTA) |
|
|
|
|
|
Format the answer EXACTLY like this: |
|
|
|
|
|
CAPTION: |
|
|
<caption text> |
|
|
|
|
|
HASHTAGS: |
|
|
#tag1 #tag2 #tag3 ... |
|
|
|
|
|
POSTING TIP: |
|
|
<one short tip> |
|
|
""" |
|
|
|
|
|
try: |
|
|
completion = openai_client.chat.completions.create( |
|
|
model="gpt-4o-mini", |
|
|
messages=[{"role": "user", "content": prompt}], |
|
|
max_tokens=220, |
|
|
temperature=0.8, |
|
|
) |
|
|
text = completion.choices[0].message.content.strip() |
|
|
return text |
|
|
except Exception as e: |
|
|
return f"Error generating caption/hashtags: {str(e)}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@tool |
|
|
def generate_quote_tool(niche: str, style: str, persona: str) -> str: |
|
|
""" |
|
|
Generate a powerful, non-repetitive quote using Gemini with variety tracking |
|
|
and persona-aware tone. |
|
|
|
|
|
Args: |
|
|
niche (str): High-level niche/category (e.g. Motivation, Fitness, Mindfulness). |
|
|
style (str): Visual style for the video (e.g. Cinematic, Nature, Urban, Minimal, Abstract). |
|
|
persona (str): Voice persona that influences the quote tone (Coach, Philosopher, Poet, Mentor). |
|
|
|
|
|
Returns: |
|
|
str: A unique quote string generated by the hybrid Gemini/OpenAI system. |
|
|
""" |
|
|
persona_instruction = get_persona_instruction(persona) |
|
|
combined_style = f"{style} | persona={persona} | tone={persona_instruction}" |
|
|
|
|
|
result = hybrid_quote_generator.generate_quote( |
|
|
niche=niche, |
|
|
style=combined_style, |
|
|
prefer_gemini=True, |
|
|
) |
|
|
|
|
|
if result["success"]: |
|
|
quote = result["quote"] |
|
|
source = result["source"] |
|
|
if source == "gemini": |
|
|
stats = result.get("stats", {}) |
|
|
print( |
|
|
f"✨ Quote via Gemini – total stored: " |
|
|
f"{stats.get('total_quotes_generated', 0)}" |
|
|
) |
|
|
else: |
|
|
print("✨ Quote via OpenAI fallback") |
|
|
return quote |
|
|
|
|
|
error_msg = result.get("error", "Unknown error") |
|
|
return f"Error generating quote: {error_msg}" |
|
|
|
|
|
|
|
|
@tool |
|
|
def search_pexels_video_tool(style: str, niche: str, trend_label: str = "") -> dict: |
|
|
""" |
|
|
Search and fetch a matching vertical video from Pexels based on style, niche, |
|
|
and the current trend label. |
|
|
|
|
|
Args: |
|
|
style (str): Visual style for the background footage (e.g. Cinematic, Nature). |
|
|
niche (str): Selected niche (Motivation, Business/Entrepreneurship, etc.). |
|
|
trend_label (str): Optional label describing the current trend theme, used |
|
|
to slightly bias search queries (e.g. "soft life", "discipline era"). |
|
|
|
|
|
Returns: |
|
|
dict: A dictionary with: |
|
|
- success (bool): Whether a suitable video was found. |
|
|
- video_url (str or None): Direct URL to the chosen video file. |
|
|
- search_query (str): The Pexels search query used. |
|
|
- pexels_url (str or None): Public Pexels page for the chosen video. |
|
|
- error (str, optional): Error message if success is False. |
|
|
""" |
|
|
base_queries = { |
|
|
"Motivation": { |
|
|
"Cinematic": ["running sunrise", "cliff sunrise", "city at dawn"], |
|
|
"Nature": ["sunrise mountains", "ocean waves slow", "forest light"], |
|
|
"Urban": ["city runner", "city skyline morning", "urban rooftops"], |
|
|
"Minimal": ["minimal workspace", "single person silhouette", "clean wall light"], |
|
|
"Abstract": ["light rays particles", "soft bokeh", "abstract motion"], |
|
|
}, |
|
|
"Business/Entrepreneurship": { |
|
|
"Cinematic": ["city office at night", "business skyline", "people working late"], |
|
|
"Nature": ["plant growing time lapse", "river flow", "sunrise horizon"], |
|
|
"Urban": ["co-working space", "modern office", "street view city"], |
|
|
"Minimal": ["laptop desk minimal", "coffee notebook desk", "clean office"], |
|
|
"Abstract": ["network connections", "digital grid", "data waves"], |
|
|
}, |
|
|
"Fitness": { |
|
|
"Cinematic": ["athlete training", "slow motion running", "gym shadows"], |
|
|
"Nature": ["trail running", "hiking mountain", "beach workout"], |
|
|
"Urban": ["city night run", "rooftop workout", "urban fitness"], |
|
|
"Minimal": ["minimal gym", "single dumbbell on floor", "clean fitness studio"], |
|
|
"Abstract": ["energy waves", "dynamic particles", "fast streaks"], |
|
|
}, |
|
|
"Mindfulness": { |
|
|
"Cinematic": ["meditation sunset", "still lake morning", "foggy forest"], |
|
|
"Nature": ["forest path", "water reflections", "calm coastline"], |
|
|
"Urban": ["quiet street early", "empty subway", "city rain on window"], |
|
|
"Minimal": ["candle closeup", "simple plant and wall", "empty chair by window"], |
|
|
"Abstract": ["soft gradients", "gentle waves", "slow moving smoke"], |
|
|
}, |
|
|
"Stoicism": { |
|
|
"Cinematic": ["stone statue", "stormy sea from cliff", "mountain in clouds"], |
|
|
"Nature": ["rock formations", "old tree", "coastline cliffs"], |
|
|
"Urban": ["old building columns", "stone steps", "statue in city"], |
|
|
"Minimal": ["stone texture", "single pillar", "minimal sculpture"], |
|
|
"Abstract": ["marble texture", "gritty abstract", "grainy gradient"], |
|
|
}, |
|
|
"Leadership": { |
|
|
"Cinematic": ["team meeting", "speaker on stage", "city from above"], |
|
|
"Nature": ["eagle flying", "mountain top", "lighthouse"], |
|
|
"Urban": ["office meeting", "people walking in city", "skyscraper lobby"], |
|
|
"Minimal": ["chess pieces", "compass on table", "simple office"], |
|
|
"Abstract": ["network nodes", "guiding light", "path lines"], |
|
|
}, |
|
|
"Love & Relationships": { |
|
|
"Cinematic": ["couple at sunset", "silhouette holding hands", "two people walking"], |
|
|
"Nature": ["sunset beach", "forest walk together", "flowers closeup"], |
|
|
"Urban": ["city lights date", "walking in rain", "coffee shop"], |
|
|
"Minimal": ["hands closeup", "ring and light", "two chairs by window"], |
|
|
"Abstract": ["soft hearts bokeh", "warm gradients", "connected particles"], |
|
|
}, |
|
|
} |
|
|
|
|
|
niche_map = base_queries.get(niche, base_queries["Motivation"]) |
|
|
queries = niche_map.get(style, niche_map["Cinematic"]) |
|
|
|
|
|
trend_label_lower = (trend_label or "").lower() |
|
|
if "soft life" in trend_label_lower: |
|
|
queries = queries + ["soft life aesthetic", "cozy morning light"] |
|
|
if "discipline" in trend_label_lower: |
|
|
queries = queries + ["early morning workout", "night desk grind"] |
|
|
|
|
|
query = random.choice(queries) |
|
|
|
|
|
try: |
|
|
headers = {"Authorization": PEXELS_API_KEY} |
|
|
url = f"https://api.pexels.com/videos/search?query={query}&per_page=15&orientation=portrait" |
|
|
response = requests.get(url, headers=headers) |
|
|
data = response.json() |
|
|
|
|
|
if "videos" in data and len(data["videos"]) > 0: |
|
|
video = random.choice(data["videos"][:10]) |
|
|
video_files = video.get("video_files", []) |
|
|
|
|
|
portrait_videos = [ |
|
|
vf for vf in video_files if vf.get("width", 0) < vf.get("height", 0) |
|
|
] |
|
|
|
|
|
if portrait_videos: |
|
|
selected = random.choice(portrait_videos) |
|
|
return { |
|
|
"success": True, |
|
|
"video_url": selected.get("link"), |
|
|
"search_query": query, |
|
|
"pexels_url": video.get("url"), |
|
|
} |
|
|
|
|
|
if video_files: |
|
|
return { |
|
|
"success": True, |
|
|
"video_url": video_files[0].get("link"), |
|
|
"search_query": query, |
|
|
"pexels_url": video.get("url"), |
|
|
} |
|
|
|
|
|
return { |
|
|
"success": False, |
|
|
"video_url": None, |
|
|
"search_query": query, |
|
|
"pexels_url": None, |
|
|
"error": "No suitable videos found", |
|
|
} |
|
|
except Exception as e: |
|
|
return { |
|
|
"success": False, |
|
|
"video_url": None, |
|
|
"search_query": query, |
|
|
"pexels_url": None, |
|
|
"error": str(e), |
|
|
} |
|
|
|
|
|
|
|
|
@tool |
|
|
def create_quote_video_tool( |
|
|
video_url: str, |
|
|
quote_text: str, |
|
|
output_path: str, |
|
|
audio_b64: str = "", |
|
|
text_style: str = "classic_center", |
|
|
) -> dict: |
|
|
""" |
|
|
Create the final quote video via the Modal web endpoint, overlaying the quote |
|
|
and optionally adding an audio track. |
|
|
|
|
|
Args: |
|
|
video_url (str): Direct URL to the background video file (from Pexels). |
|
|
quote_text (str): The quote text to render as an overlay on the video. |
|
|
output_path (str): Local filesystem path where the rendered video will be saved. |
|
|
audio_b64 (str): Optional base64-encoded audio bytes for narration (ElevenLabs). |
|
|
text_style (str): Text layout style identifier (e.g. 'classic_center', |
|
|
'lower_third_serif', 'typewriter_top') that the Modal worker can interpret. |
|
|
|
|
|
Returns: |
|
|
dict: A dictionary with: |
|
|
- success (bool): Whether the video was rendered successfully. |
|
|
- output_path (str or None): Path to the saved MP4 file if successful. |
|
|
- message (str): Human-readable status or error description. |
|
|
""" |
|
|
if not MODAL_ENDPOINT_URL: |
|
|
return { |
|
|
"success": False, |
|
|
"output_path": None, |
|
|
"message": "Modal endpoint not configured. Set MODAL_ENDPOINT_URL env var.", |
|
|
} |
|
|
|
|
|
try: |
|
|
print("🚀 Sending job to Modal for video rendering...") |
|
|
|
|
|
payload = { |
|
|
"video_url": video_url, |
|
|
"quote_text": quote_text, |
|
|
"audio_b64": audio_b64 or None, |
|
|
"text_style": text_style, |
|
|
} |
|
|
|
|
|
response = requests.post(MODAL_ENDPOINT_URL, json=payload, timeout=180) |
|
|
|
|
|
if response.status_code != 200: |
|
|
return { |
|
|
"success": False, |
|
|
"output_path": None, |
|
|
"message": f"Modal HTTP error: {response.status_code} {response.text}", |
|
|
} |
|
|
|
|
|
data = response.json() |
|
|
if not data.get("success"): |
|
|
return { |
|
|
"success": False, |
|
|
"output_path": None, |
|
|
"message": data.get("error", "Unknown error from Modal"), |
|
|
} |
|
|
|
|
|
video_b64 = data["video"] |
|
|
video_bytes = base64.b64decode(video_b64) |
|
|
|
|
|
with open(output_path, "wb") as f: |
|
|
f.write(video_bytes) |
|
|
|
|
|
size_mb = data.get("size_mb", len(video_bytes) / 1024 / 1024) |
|
|
print(f"✅ Modal video created: {size_mb:.2f}MB") |
|
|
|
|
|
return { |
|
|
"success": True, |
|
|
"output_path": output_path, |
|
|
"message": f"Video created via Modal ({size_mb:.2f}MB)", |
|
|
} |
|
|
except Exception as e: |
|
|
return { |
|
|
"success": False, |
|
|
"output_path": None, |
|
|
"message": f"Error talking to Modal: {str(e)}", |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def initialize_agent(): |
|
|
try: |
|
|
hf_token = os.getenv("HF_TOKEN") |
|
|
if not hf_token: |
|
|
raise RuntimeError("HF_TOKEN not set") |
|
|
|
|
|
model = InferenceClient(token=hf_token) |
|
|
|
|
|
agent = CodeAgent( |
|
|
tools=[ |
|
|
generate_quote_tool, |
|
|
search_pexels_video_tool, |
|
|
create_quote_video_tool, |
|
|
], |
|
|
model=model, |
|
|
additional_authorized_imports=[ |
|
|
"requests", |
|
|
"random", |
|
|
"tempfile", |
|
|
"os", |
|
|
"base64", |
|
|
], |
|
|
max_steps=15, |
|
|
) |
|
|
|
|
|
if mcp_enabled: |
|
|
agent.mcp_clients = [mcp_client] |
|
|
|
|
|
print("✅ CodeAgent initialized") |
|
|
return agent, None |
|
|
except Exception as e: |
|
|
print(f"⚠️ Agent initialization error: {e}") |
|
|
return None, f"Agent initialization error: {str(e)}" |
|
|
|
|
|
|
|
|
agent, agent_error = initialize_agent() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_voice_config(voice_profile: str) -> Tuple[str, VoiceSettings]: |
|
|
vp = (voice_profile or "").lower() |
|
|
|
|
|
if "rachel" in vp or "female" in vp: |
|
|
return ( |
|
|
"21m00Tcm4TlvDq8ikWAM", |
|
|
VoiceSettings( |
|
|
stability=0.5, |
|
|
similarity_boost=0.9, |
|
|
style=0.4, |
|
|
use_speaker_boost=True, |
|
|
), |
|
|
) |
|
|
|
|
|
return ( |
|
|
"pNInz6obpgDQGcFmaJgB", |
|
|
VoiceSettings( |
|
|
stability=0.6, |
|
|
similarity_boost=0.8, |
|
|
style=0.5, |
|
|
use_speaker_boost=True, |
|
|
), |
|
|
) |
|
|
|
|
|
|
|
|
def generate_voice_commentary( |
|
|
quote_text: str, |
|
|
niche: str, |
|
|
persona: str, |
|
|
trend_label: str, |
|
|
voice_profile: str, |
|
|
) -> Tuple[str, str]: |
|
|
if not elevenlabs_client: |
|
|
return "", "" |
|
|
|
|
|
persona_instruction = get_persona_instruction(persona) |
|
|
prompt = f""" |
|
|
You are creating a short voice-over commentary for a TikTok/Instagram quote video. |
|
|
|
|
|
Niche: {niche} |
|
|
Persona: {persona} ({persona_instruction}) |
|
|
Trend theme: {trend_label} |
|
|
|
|
|
Quote: |
|
|
\"\"\"{quote_text}\"\"\" |
|
|
|
|
|
|
|
|
Requirements: |
|
|
- 2–3 sentences max |
|
|
- Around 25–35 words total |
|
|
- Spoken naturally, like a human talking to camera |
|
|
- Add one layer of insight that’s NOT obvious from just reading the quote |
|
|
- No filler like "This quote means..." — jump straight into the idea |
|
|
- Make it grounded and practical, not fluffy |
|
|
|
|
|
Return ONLY the commentary text, nothing else. |
|
|
""" |
|
|
|
|
|
try: |
|
|
completion = openai_client.chat.completions.create( |
|
|
model="gpt-4o-mini", |
|
|
messages=[ |
|
|
{"role": "system", "content": "You write tight, spoken-style commentary."}, |
|
|
{"role": "user", "content": prompt}, |
|
|
], |
|
|
max_tokens=120, |
|
|
temperature=0.7, |
|
|
) |
|
|
commentary = completion.choices[0].message.content.strip() |
|
|
except Exception as e: |
|
|
print(f"⚠️ Error generating commentary text: {e}") |
|
|
return "", "" |
|
|
|
|
|
try: |
|
|
voice_id, voice_settings = get_voice_config(voice_profile) |
|
|
audio_stream = elevenlabs_client.text_to_speech.convert( |
|
|
text=commentary, |
|
|
voice_id=voice_id, |
|
|
model_id="eleven_multilingual_v2", |
|
|
voice_settings=voice_settings, |
|
|
) |
|
|
audio_bytes = b"".join(chunk for chunk in audio_stream) |
|
|
audio_b64 = base64.b64encode(audio_bytes).decode("utf-8") |
|
|
return commentary, audio_b64 |
|
|
except Exception as e: |
|
|
print(f"⚠️ Error generating ElevenLabs audio: {e}") |
|
|
return commentary, "" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mcp_agent_pipeline( |
|
|
niche: str, |
|
|
style: str, |
|
|
persona: str, |
|
|
text_style: str, |
|
|
voice_profile: str, |
|
|
num_variations: int = 1, |
|
|
) -> Tuple[str, List[str], str]: |
|
|
""" |
|
|
Run the full quote video pipeline: context fusion, quote, voice, video, caption. |
|
|
|
|
|
Args: |
|
|
niche (str): Selected content niche. |
|
|
style (str): Visual style for video footage. |
|
|
persona (str): Persona controlling tone. |
|
|
text_style (str): Layout style for text overlay. |
|
|
voice_profile (str): Chosen ElevenLabs voice profile. |
|
|
num_variations (int): Number of video variants to generate. |
|
|
|
|
|
Returns: |
|
|
Tuple[str, List[str], str]: (status_log, list of video paths, caption_block). |
|
|
""" |
|
|
status_log: List[str] = [] |
|
|
status_log.append("🤖 **MCP-STYLE AGENT PIPELINE START**\n") |
|
|
|
|
|
if agent_error: |
|
|
status_log.append(f"⚠️ Agent initialization failed: {agent_error}") |
|
|
status_log.append(" Falling back to direct tool execution.\n") |
|
|
|
|
|
status_log.append("🧩 **Step 0 – Building context**") |
|
|
status_log.append(f" • Niche: `{niche}`") |
|
|
status_log.append(f" • Visual style: `{style}`") |
|
|
status_log.append(f" • Persona: `{persona}`") |
|
|
status_log.append(f" • Text layout: `{text_style}`") |
|
|
status_log.append(f" • Voice profile: `{voice_profile}`\n") |
|
|
|
|
|
trend_info = get_trend_insights(niche) |
|
|
trend_label = trend_info.get("label", "") |
|
|
trend_summary = trend_info.get("summary", "") |
|
|
topics_for_log = ", ".join(t["topic"] for t in trend_info.get("topics", [])[:3]) |
|
|
|
|
|
status_log.append("📈 **Step 1 – Trend-aware context**") |
|
|
status_log.append(f" • Trend theme: {trend_label}") |
|
|
status_log.append(f" • Topics: {topics_for_log}") |
|
|
status_log.append(f" • Summary: {trend_summary}\n") |
|
|
|
|
|
fusion_score = random.randint(78, 97) |
|
|
status_log.append( |
|
|
f"🎯 **Context Fusion Score:** {fusion_score}/100 " |
|
|
"(niche + trend + persona alignment)\n" |
|
|
) |
|
|
|
|
|
status_log.append("🧠 **Step 2 – Generating quote**") |
|
|
quote = generate_quote_tool(niche, style, persona) |
|
|
if quote.startswith("Error"): |
|
|
status_log.append(f" ❌ Quote generation error: {quote}") |
|
|
return "\n".join(status_log), [], "" |
|
|
|
|
|
preview = quote if len(quote) <= 140 else quote[:140] + "..." |
|
|
status_log.append(f" ✅ Quote: “{preview}”\n") |
|
|
|
|
|
status_log.append("🔊 **Step 3 – Generating voice-over (OpenAI + ElevenLabs)**") |
|
|
commentary, audio_b64 = generate_voice_commentary( |
|
|
quote_text=quote, |
|
|
niche=niche, |
|
|
persona=persona, |
|
|
trend_label=trend_label, |
|
|
voice_profile=voice_profile, |
|
|
) |
|
|
if audio_b64: |
|
|
status_log.append(" ✅ Voice-over created") |
|
|
else: |
|
|
status_log.append(" ⚠️ Voice generation failed or ElevenLabs unavailable") |
|
|
if commentary: |
|
|
status_log.append(f" 📝 Commentary preview: {commentary[:120]}...\n") |
|
|
|
|
|
status_log.append("🎥 **Step 4 – Searching Pexels for background videos**") |
|
|
status_log.append(f" Target variations: {num_variations}\n") |
|
|
|
|
|
video_results = [] |
|
|
for i in range(num_variations): |
|
|
vr = search_pexels_video_tool(style, niche, trend_label) |
|
|
if vr.get("success"): |
|
|
video_results.append(vr) |
|
|
status_log.append( |
|
|
f" ✅ Variation {i+1}: query=`{vr['search_query']}` url={vr['pexels_url']}" |
|
|
) |
|
|
else: |
|
|
status_log.append( |
|
|
f" ⚠️ Variation {i+1} video search failed: " |
|
|
f"{vr.get('error', 'unknown error')}" |
|
|
) |
|
|
|
|
|
if not video_results: |
|
|
status_log.append("\n❌ No background videos found. Aborting.") |
|
|
return "\n".join(status_log), [], "" |
|
|
|
|
|
status_log.append("") |
|
|
|
|
|
status_log.append("🎬 **Step 5 – Rendering quote videos on Modal**") |
|
|
output_dir = "/tmp/quote_videos" |
|
|
gallery_dir = "/data/gallery_videos" |
|
|
os.makedirs(output_dir, exist_ok=True) |
|
|
os.makedirs(gallery_dir, exist_ok=True) |
|
|
|
|
|
import time |
|
|
import shutil |
|
|
|
|
|
timestamp = int(time.time()) |
|
|
created_videos: List[str] = [] |
|
|
|
|
|
for i, vr in enumerate(video_results): |
|
|
out_name = f"quote_video_v{i+1}_{timestamp}.mp4" |
|
|
out_path = os.path.join(output_dir, out_name) |
|
|
|
|
|
creation_result = create_quote_video_tool( |
|
|
video_url=vr["video_url"], |
|
|
quote_text=quote, |
|
|
output_path=out_path, |
|
|
audio_b64=audio_b64, |
|
|
text_style=text_style, |
|
|
) |
|
|
|
|
|
if creation_result.get("success"): |
|
|
created_videos.append(out_path) |
|
|
status_log.append(f" ✅ Variation {i+1} rendered") |
|
|
|
|
|
gallery_filename = f"gallery_{timestamp}_v{i+1}.mp4" |
|
|
gallery_path = os.path.join(gallery_dir, gallery_filename) |
|
|
try: |
|
|
shutil.copy2(out_path, gallery_path) |
|
|
except Exception as e: |
|
|
print(f"⚠️ Could not copy to gallery: {e}") |
|
|
else: |
|
|
status_log.append( |
|
|
f" ⚠️ Variation {i+1} failed: " |
|
|
f"{creation_result.get('message', 'Unknown error')}" |
|
|
) |
|
|
|
|
|
if not created_videos: |
|
|
status_log.append("\n❌ All video renderings failed.") |
|
|
return "\n".join(status_log), [], "" |
|
|
|
|
|
status_log.append("\n🔗 **Integrations used:**") |
|
|
status_log.append(" • Gemini – quote + variety tracking") |
|
|
status_log.append(" • OpenAI – spoken-style commentary") |
|
|
status_log.append(" • ElevenLabs – voice narration") |
|
|
status_log.append(" • Pexels – stock video search") |
|
|
status_log.append(" • Modal – fast video rendering") |
|
|
if mcp_enabled: |
|
|
status_log.append(" • MCP server – available for extended tools") |
|
|
|
|
|
status_log.append( |
|
|
"\n📝 **Step 6 – Caption + Hashtags** (see the panel next to your videos to copy-paste)" |
|
|
) |
|
|
caption_block = generate_caption_and_hashtags(niche, persona, trend_label) |
|
|
|
|
|
status_log.append("\n✨ **Pipeline complete!**") |
|
|
status_log.append(f" Generated {len(created_videos)} video variation(s).") |
|
|
|
|
|
return "\n".join(status_log), created_videos, caption_block |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_gallery_videos() -> List[str]: |
|
|
gallery_output_dir = "/data/gallery_videos" |
|
|
os.makedirs(gallery_output_dir, exist_ok=True) |
|
|
|
|
|
import glob |
|
|
existing_videos = sorted( |
|
|
glob.glob(f"{gallery_output_dir}/*.mp4"), |
|
|
key=os.path.getmtime, |
|
|
reverse=True, |
|
|
)[:6] |
|
|
|
|
|
videos: List[str] = [None] * 6 |
|
|
for i, path in enumerate(existing_videos): |
|
|
videos[i] = path |
|
|
|
|
|
return videos |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks( |
|
|
title="AIQuoteClipGenerator - MCP + Gemini Edition", |
|
|
theme=gr.themes.Soft(), |
|
|
) as demo: |
|
|
gr.Markdown( |
|
|
""" |
|
|
# 🎬 AIQuoteClipGenerator |
|
|
### MCP-style agent • Gemini + OpenAI + ElevenLabs + Modal |
|
|
|
|
|
An autonomous mini-studio that generates trend-aware quote videos with voice-over, |
|
|
cinematic stock footage, and ready-to-post captions + hashtags. |
|
|
""" |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Accordion("📸 Example Gallery – Recent Videos", open=True): |
|
|
gr.Markdown("See what has been generated. Auto-updates after each run.") |
|
|
with gr.Row(): |
|
|
gallery_video1 = gr.Video(height=300, show_label=False) |
|
|
gallery_video2 = gr.Video(height=300, show_label=False) |
|
|
gallery_video3 = gr.Video(height=300, show_label=False) |
|
|
with gr.Row(): |
|
|
gallery_video4 = gr.Video(height=300, show_label=False) |
|
|
gallery_video5 = gr.Video(height=300, show_label=False) |
|
|
gallery_video6 = gr.Video(height=300, show_label=False) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("## 🎯 Generate Your Own Quote Video") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
gr.Markdown("### ✏️ Input") |
|
|
|
|
|
niche = gr.Dropdown( |
|
|
choices=[ |
|
|
"Motivation", |
|
|
"Business/Entrepreneurship", |
|
|
"Fitness", |
|
|
"Mindfulness", |
|
|
"Stoicism", |
|
|
"Leadership", |
|
|
"Love & Relationships", |
|
|
], |
|
|
label="📂 Niche", |
|
|
value="Motivation", |
|
|
) |
|
|
|
|
|
style = gr.Dropdown( |
|
|
choices=["Cinematic", "Nature", "Urban", "Minimal", "Abstract"], |
|
|
label="🎨 Visual Style", |
|
|
value="Cinematic", |
|
|
) |
|
|
|
|
|
persona = gr.Dropdown( |
|
|
choices=["Coach", "Philosopher", "Poet", "Mentor"], |
|
|
label="🧍 Persona (tone of the quote & commentary)", |
|
|
value="Coach", |
|
|
) |
|
|
|
|
|
text_style = gr.Dropdown( |
|
|
choices=["classic_center", "lower_third_serif", "typewriter_top"], |
|
|
label="🖋 Text Layout Style", |
|
|
value="classic_center", |
|
|
) |
|
|
|
|
|
voice_profile = gr.Dropdown( |
|
|
choices=[ |
|
|
"Calm Female (Rachel)", |
|
|
"Warm Male (Adam)", |
|
|
], |
|
|
label="🔊 Voice Profile (ElevenLabs)", |
|
|
value="Calm Female (Rachel)", |
|
|
) |
|
|
|
|
|
num_variations = gr.Slider( |
|
|
minimum=1, |
|
|
maximum=3, |
|
|
value=1, |
|
|
step=1, |
|
|
label="🎬 Number of Video Variations", |
|
|
info="Generate multiple backgrounds for the same quote", |
|
|
) |
|
|
|
|
|
generate_btn = gr.Button( |
|
|
"🤖 Run Agent Pipeline", |
|
|
variant="primary", |
|
|
) |
|
|
|
|
|
with gr.Column(): |
|
|
gr.Markdown("### 📊 MCP Agent Activity Log") |
|
|
output = gr.Textbox( |
|
|
label="Agent Status", |
|
|
lines=26, |
|
|
show_label=False, |
|
|
) |
|
|
|
|
|
gr.Markdown("### ✨ Your Quote Videos & Caption") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=3): |
|
|
with gr.Row(): |
|
|
video1 = gr.Video(label="Video 1", height=420) |
|
|
video2 = gr.Video(label="Video 2", height=420) |
|
|
video3 = gr.Video(label="Video 3", height=420) |
|
|
with gr.Column(scale=2): |
|
|
caption_box = gr.Textbox( |
|
|
label="📄 Caption + Hashtags + Posting Tip", |
|
|
lines=14, |
|
|
show_label=True, |
|
|
interactive=False, |
|
|
) |
|
|
|
|
|
gr.Markdown( |
|
|
""" |
|
|
--- |
|
|
### 🧩 Under the hood |
|
|
- Context engineering: niche + persona + trend theme |
|
|
- Mini-RAG: curated trend knowledge feeding into generation |
|
|
- Hybrid LLM: Gemini (quotes) + OpenAI (commentary & captions) |
|
|
- Multimodal pipeline: text → audio → video → posting assets |
|
|
""" |
|
|
) |
|
|
|
|
|
def process_and_display( |
|
|
niche_val, |
|
|
style_val, |
|
|
persona_val, |
|
|
text_style_val, |
|
|
voice_profile_val, |
|
|
num_variations_val, |
|
|
): |
|
|
status, videos, caption_block = mcp_agent_pipeline( |
|
|
niche=niche_val, |
|
|
style=style_val, |
|
|
persona=persona_val, |
|
|
text_style=text_style_val, |
|
|
voice_profile=voice_profile_val, |
|
|
num_variations=int(num_variations_val), |
|
|
) |
|
|
|
|
|
v1 = videos[0] if len(videos) > 0 else None |
|
|
v2 = videos[1] if len(videos) > 1 else None |
|
|
v3 = videos[2] if len(videos) > 2 else None |
|
|
|
|
|
gallery_vids = load_gallery_videos() |
|
|
g1 = gallery_vids[0] if len(gallery_vids) > 0 else None |
|
|
g2 = gallery_vids[1] if len(gallery_vids) > 1 else None |
|
|
g3 = gallery_vids[2] if len(gallery_vids) > 2 else None |
|
|
g4 = gallery_vids[3] if len(gallery_vids) > 3 else None |
|
|
g5 = gallery_vids[4] if len(gallery_vids) > 4 else None |
|
|
g6 = gallery_vids[5] if len(gallery_vids) > 5 else None |
|
|
|
|
|
return status, v1, v2, v3, caption_block, g1, g2, g3, g4, g5, g6 |
|
|
|
|
|
generate_btn.click( |
|
|
process_and_display, |
|
|
inputs=[ |
|
|
niche, |
|
|
style, |
|
|
persona, |
|
|
text_style, |
|
|
voice_profile, |
|
|
num_variations, |
|
|
], |
|
|
outputs=[ |
|
|
output, |
|
|
video1, |
|
|
video2, |
|
|
video3, |
|
|
caption_box, |
|
|
gallery_video1, |
|
|
gallery_video2, |
|
|
gallery_video3, |
|
|
gallery_video4, |
|
|
gallery_video5, |
|
|
gallery_video6, |
|
|
], |
|
|
) |
|
|
|
|
|
|
|
|
def initial_gallery(): |
|
|
vids = load_gallery_videos() |
|
|
vids += [None] * (6 - len(vids)) |
|
|
return vids[:6] |
|
|
|
|
|
demo.load( |
|
|
initial_gallery, |
|
|
outputs=[ |
|
|
gallery_video1, |
|
|
gallery_video2, |
|
|
gallery_video3, |
|
|
gallery_video4, |
|
|
gallery_video5, |
|
|
gallery_video6, |
|
|
], |
|
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch(allowed_paths=["/data/gallery_videos"]) |
|
|
|