Update app.py
Browse files
app.py
CHANGED
|
@@ -1,149 +1,103 @@
|
|
| 1 |
-
"""
|
| 2 |
-
AI Reels Maker — Fixed Version
|
| 3 |
-
- Single Pexels background video (looped)
|
| 4 |
-
- Outro video appended at the end
|
| 5 |
-
- No caption background/shadow
|
| 6 |
-
- Voice selection works
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
import sys
|
| 10 |
sys.stdout.reconfigure(line_buffering=True)
|
| 11 |
|
| 12 |
-
import asyncio
|
| 13 |
import os
|
| 14 |
import re
|
| 15 |
import tempfile
|
| 16 |
-
import
|
| 17 |
-
from pathlib import Path
|
| 18 |
-
|
| 19 |
-
import gradio as gr
|
| 20 |
-
import numpy as np
|
| 21 |
import requests
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# Constants
|
| 27 |
-
VIDEO_W = 720
|
| 28 |
-
VIDEO_H = 1280
|
| 29 |
-
FPS = 24
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
# Voice configs
|
| 35 |
EDGE_VOICES = {
|
| 36 |
"Aria (US Female)": "en-US-AriaNeural",
|
| 37 |
"Davis (US Male)": "en-US-DavisNeural",
|
| 38 |
"Jenny (US Female)": "en-US-JennyNeural",
|
| 39 |
"Guy (US Male)": "en-US-GuyNeural",
|
| 40 |
-
"Sonia (UK Female)": "en-GB-SoniaNeural",
|
| 41 |
-
"Ryan (UK Male)": "en-GB-RyanNeural",
|
| 42 |
}
|
| 43 |
-
|
| 44 |
|
| 45 |
-
#
|
| 46 |
-
REEL_TYPES = ["Fact", "Custom Prompt", "Top 5", "Blog to Reel"]
|
| 47 |
-
REEL_PROMPTS = {
|
| 48 |
-
"Fact": (
|
| 49 |
-
"Create a FACTUAL reel script based STRICTLY on the provided news article.\n"
|
| 50 |
-
"Do NOT invent facts. Each line: 15-20 words.\n"
|
| 51 |
-
"The LAST line MUST be a call-to-action with the source domain (e.g., 'Visit ChainStreet.io for the full story').\n"
|
| 52 |
-
"Output exactly {n} lines, one per line."
|
| 53 |
-
),
|
| 54 |
-
"Custom Prompt": (
|
| 55 |
-
"You are a senior financial journalist. Write a FACTUAL script based ONLY on the provided news content.\n"
|
| 56 |
-
"No speculation. Last line: call-to-action with source domain.\n"
|
| 57 |
-
"Each line: 15-20 words. Output {n} lines."
|
| 58 |
-
),
|
| 59 |
-
}
|
| 60 |
-
DEFAULT_PROMPT = REEL_PROMPTS["Fact"]
|
| 61 |
-
|
| 62 |
-
# Font loader
|
| 63 |
-
_font = None
|
| 64 |
def get_font(size=64):
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
urllib.request.urlretrieve(FONT_BOLD_URL, FONT_BOLD_PATH)
|
| 70 |
-
_font = ImageFont.truetype(FONT_BOLD_PATH, size)
|
| 71 |
-
except:
|
| 72 |
-
_font = ImageFont.load_default()
|
| 73 |
-
return _font
|
| 74 |
|
| 75 |
-
# Scrape
|
| 76 |
def scrape_url(url):
|
| 77 |
try:
|
| 78 |
import trafilatura
|
| 79 |
text = trafilatura.extract(trafilatura.fetch_url(url))
|
| 80 |
return text.strip() if text else ""
|
| 81 |
except Exception as e:
|
| 82 |
-
print(f"
|
| 83 |
return ""
|
| 84 |
|
| 85 |
-
#
|
| 86 |
-
def generate_script(content, groq_key,
|
| 87 |
from groq import Groq
|
| 88 |
client = Groq(api_key=groq_key.strip())
|
| 89 |
-
prompt =
|
|
|
|
|
|
|
|
|
|
| 90 |
resp = client.chat.completions.create(
|
| 91 |
model="llama-3.3-70b-versatile",
|
| 92 |
-
messages=[
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
max_tokens=700,
|
| 96 |
)
|
| 97 |
-
lines = [l.strip() for l in resp.choices[0].message.content.
|
| 98 |
return lines[:num_points]
|
| 99 |
|
| 100 |
-
# Edge TTS
|
| 101 |
-
async def
|
| 102 |
import edge_tts
|
| 103 |
await edge_tts.Communicate(text, voice).save(path)
|
| 104 |
|
| 105 |
def generate_audio(text, voice_key):
|
| 106 |
-
|
| 107 |
-
raise ValueError("Empty text")
|
| 108 |
-
voice_id = EDGE_VOICES.get(voice_key, EDGE_VOICES[DEFAULT_VOICE_KEY])
|
| 109 |
-
print(f"[TTS] Using voice: {voice_id} (key: {voice_key})")
|
| 110 |
out = tempfile.mktemp(suffix=".mp3")
|
| 111 |
loop = asyncio.new_event_loop()
|
| 112 |
try:
|
| 113 |
-
loop.run_until_complete(
|
| 114 |
finally:
|
| 115 |
loop.close()
|
| 116 |
return out
|
| 117 |
|
| 118 |
-
#
|
| 119 |
def fetch_pexels_video(query, api_key):
|
| 120 |
if not api_key:
|
| 121 |
-
print("[Pexels] No API key")
|
| 122 |
return None
|
| 123 |
try:
|
| 124 |
resp = requests.get(
|
| 125 |
"https://api.pexels.com/videos/search",
|
| 126 |
headers={"Authorization": api_key},
|
| 127 |
params={"query": query, "per_page": 5, "orientation": "portrait"},
|
| 128 |
-
timeout=
|
| 129 |
)
|
| 130 |
data = resp.json()
|
| 131 |
videos = data.get("videos", [])
|
| 132 |
-
if not videos:
|
| 133 |
-
print(f"[Pexels] No videos for '{query}'")
|
| 134 |
-
return None
|
| 135 |
-
# pick first portrait video
|
| 136 |
for vid in videos:
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
url = f["link"]
|
| 141 |
break
|
| 142 |
else:
|
| 143 |
# fallback to any portrait
|
| 144 |
-
for
|
| 145 |
-
if
|
| 146 |
-
url =
|
| 147 |
break
|
| 148 |
else:
|
| 149 |
continue
|
|
@@ -153,27 +107,25 @@ def fetch_pexels_video(query, api_key):
|
|
| 153 |
with open(out, "wb") as f:
|
| 154 |
for chunk in r.iter_content(32768):
|
| 155 |
f.write(chunk)
|
| 156 |
-
print(f"[Pexels] Downloaded: {out}")
|
| 157 |
return out
|
| 158 |
except Exception as e:
|
| 159 |
-
print(f"
|
| 160 |
return None
|
| 161 |
|
| 162 |
-
# Create
|
| 163 |
-
def create_reel(sentences, audio_path, bg_video_path, logo_path,
|
| 164 |
import moviepy.editor as mpe
|
| 165 |
-
from moviepy.video.fx import loop
|
| 166 |
|
| 167 |
W, H = VIDEO_W, VIDEO_H
|
| 168 |
-
# Load audio
|
| 169 |
audio = mpe.AudioFileClip(audio_path)
|
| 170 |
total_dur = audio.duration
|
| 171 |
-
|
| 172 |
|
| 173 |
-
# Background
|
| 174 |
if bg_video_path and os.path.exists(bg_video_path):
|
| 175 |
bg = mpe.VideoFileClip(bg_video_path)
|
| 176 |
-
# Resize to
|
| 177 |
if bg.w / bg.h > W / H:
|
| 178 |
bg = bg.resize(height=H)
|
| 179 |
else:
|
|
@@ -182,23 +134,23 @@ def create_reel(sentences, audio_path, bg_video_path, logo_path, accent_hex, out
|
|
| 182 |
if bg.duration < total_dur:
|
| 183 |
bg = mpe.concatenate_videoclips([bg] * int(np.ceil(total_dur / bg.duration)))
|
| 184 |
bg = bg.subclip(0, total_dur)
|
| 185 |
-
#
|
| 186 |
dark = mpe.ColorClip((W, H), color=(0,0,0)).set_opacity(0.3).set_duration(total_dur)
|
| 187 |
bg_layer = mpe.CompositeVideoClip([bg, dark])
|
| 188 |
else:
|
| 189 |
-
#
|
| 190 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
| 191 |
for i in range(H):
|
| 192 |
frame[i] = [int(30 + i/H*100), int(20 + i/H*50), int(40 + i/H*80)]
|
| 193 |
bg_layer = mpe.ImageClip(frame).set_duration(total_dur)
|
| 194 |
|
| 195 |
-
# Text clips (no background
|
| 196 |
-
def make_text_clip(
|
| 197 |
img = Image.new("RGBA", (W, H), (0,0,0,0))
|
| 198 |
draw = ImageDraw.Draw(img)
|
| 199 |
font = get_font(60)
|
| 200 |
-
#
|
| 201 |
-
words =
|
| 202 |
lines = []
|
| 203 |
cur = []
|
| 204 |
for w in words:
|
|
@@ -216,26 +168,24 @@ def create_reel(sentences, audio_path, bg_video_path, logo_path, accent_hex, out
|
|
| 216 |
for line in lines:
|
| 217 |
bbox = draw.textbbox((0,0), line, font=font)
|
| 218 |
x = (W - (bbox[2]-bbox[0]))//2
|
| 219 |
-
draw.text((x, y), line, font=font, fill=(255,255,255
|
| 220 |
y += line_h
|
| 221 |
-
#
|
| 222 |
if logo_path and os.path.exists(logo_path):
|
| 223 |
try:
|
| 224 |
logo = Image.open(logo_path).convert("RGBA")
|
| 225 |
logo = logo.resize((180, int(180*logo.height/logo.width)), Image.LANCZOS)
|
| 226 |
img.paste(logo, (W-180-30, 30), logo)
|
| 227 |
except Exception as e:
|
| 228 |
-
print(f"
|
| 229 |
-
|
| 230 |
-
return clip
|
| 231 |
|
| 232 |
-
text_clips = [make_text_clip(s, i*
|
| 233 |
final = mpe.CompositeVideoClip([bg_layer] + text_clips).set_audio(audio)
|
| 234 |
|
| 235 |
-
# Append outro
|
| 236 |
if outro_path and os.path.exists(outro_path):
|
| 237 |
outro = mpe.VideoFileClip(outro_path)
|
| 238 |
-
# resize to same dimensions
|
| 239 |
if outro.w / outro.h > W / H:
|
| 240 |
outro = outro.resize(height=H)
|
| 241 |
else:
|
|
@@ -244,33 +194,31 @@ def create_reel(sentences, audio_path, bg_video_path, logo_path, accent_hex, out
|
|
| 244 |
final = mpe.concatenate_videoclips([final, outro])
|
| 245 |
|
| 246 |
out = tempfile.mktemp(suffix=".mp4")
|
| 247 |
-
final.write_videofile(out, codec="libx264", audio_codec="aac", fps=FPS, preset="
|
| 248 |
return out
|
| 249 |
|
| 250 |
-
# Gradio
|
| 251 |
-
def generate_script_only(url_or_text, groq_key,
|
| 252 |
if not groq_key:
|
| 253 |
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 254 |
if not groq_key:
|
| 255 |
-
return "", "❌ Groq API key
|
| 256 |
raw = url_or_text.strip()
|
| 257 |
if raw.startswith("http"):
|
| 258 |
content = scrape_url(raw)
|
| 259 |
if not content:
|
| 260 |
-
return "", "❌ Could not extract
|
| 261 |
else:
|
| 262 |
content = raw
|
| 263 |
-
|
| 264 |
-
return "", "❌ Please enter a valid URL or topic"
|
| 265 |
-
sentences = generate_script(content, groq_key, reel_type, int(num_points))
|
| 266 |
if not sentences:
|
| 267 |
return "", "❌ Script generation failed"
|
| 268 |
script_text = "\n".join(sentences)
|
| 269 |
md = "\n\n".join(f"**{i+1}.** {s}" for i,s in enumerate(sentences))
|
| 270 |
return script_text, f"## Script Ready\n\n{md}"
|
| 271 |
|
| 272 |
-
def create_video_from_script(script_text, groq_key, pexels_key,
|
| 273 |
-
logo_file,
|
| 274 |
if not groq_key:
|
| 275 |
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 276 |
if not groq_key:
|
|
@@ -282,60 +230,44 @@ def create_video_from_script(script_text, groq_key, pexels_key, tts_engine, edge
|
|
| 282 |
if not sentences:
|
| 283 |
return None, "❌ No script"
|
| 284 |
full_script = " ".join(sentences)
|
| 285 |
-
#
|
| 286 |
-
|
| 287 |
-
audio_path = generate_audio(full_script, edge_voice)
|
| 288 |
-
else:
|
| 289 |
-
# Kokoro fallback to Edge
|
| 290 |
-
audio_path = generate_audio(full_script, edge_voice)
|
| 291 |
|
| 292 |
-
#
|
| 293 |
bg_video = None
|
| 294 |
if pexels_key:
|
| 295 |
-
#
|
| 296 |
kw = re.sub(r'[^\w\s]', '', sentences[0]).split()[:3]
|
| 297 |
kw = " ".join(kw) if kw else "news"
|
| 298 |
-
print(f"[Pexels] Searching for: {kw}")
|
| 299 |
bg_video = fetch_pexels_video(kw, pexels_key)
|
| 300 |
-
if not bg_video:
|
| 301 |
-
print("[Pexels] No video found, using gradient")
|
| 302 |
|
| 303 |
logo_path = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
|
| 304 |
-
outro_path =
|
| 305 |
|
| 306 |
-
video_path = create_reel(sentences, audio_path, bg_video, logo_path,
|
| 307 |
md = "\n\n".join(f"**{i+1}.** {s}" for i,s in enumerate(sentences))
|
| 308 |
return video_path, f"## Reel Ready!\n\n{md}"
|
| 309 |
|
| 310 |
-
#
|
| 311 |
with gr.Blocks(title="AI Reels Maker") as demo:
|
| 312 |
-
gr.Markdown("# 🎬 AI Reels Maker\nFactual news reels with background
|
| 313 |
-
with gr.
|
| 314 |
-
with gr.
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
outro_video = gr.File(label="Outro Video (optional)", type="filepath")
|
| 329 |
-
num_points = gr.Slider(6, 10, value=8, step=1, label="Number of sentences")
|
| 330 |
-
gen_btn = gr.Button("Generate Script")
|
| 331 |
-
create_btn = gr.Button("Create Video")
|
| 332 |
-
with gr.Column():
|
| 333 |
-
script_editor = gr.TextArea(label="Edit Script", lines=10, interactive=True)
|
| 334 |
-
video_out = gr.Video(label="Your Reel")
|
| 335 |
-
status = gr.Markdown()
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
create_btn.click(create_video_from_script, inputs=[script_editor, groq_key, pexels_key, tts_engine, edge_voice, kokoro_voice, logo_file, logo_pos, accent_hex, gr.Checkbox(value=False, visible=False), outro_video], outputs=[video_out, status])
|
| 340 |
|
| 341 |
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
sys.stdout.reconfigure(line_buffering=True)
|
| 3 |
|
|
|
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import tempfile
|
| 7 |
+
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import requests
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 11 |
+
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
# ---------- Constants ----------
|
| 14 |
+
VIDEO_W, VIDEO_H, FPS = 720, 1280, 24
|
| 15 |
+
FONT_URL = "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat-Bold.ttf"
|
| 16 |
+
FONT_PATH = "/tmp/Montserrat-Bold.ttf"
|
| 17 |
|
|
|
|
| 18 |
EDGE_VOICES = {
|
| 19 |
"Aria (US Female)": "en-US-AriaNeural",
|
| 20 |
"Davis (US Male)": "en-US-DavisNeural",
|
| 21 |
"Jenny (US Female)": "en-US-JennyNeural",
|
| 22 |
"Guy (US Male)": "en-US-GuyNeural",
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
+
DEFAULT_VOICE = "Aria (US Female)"
|
| 25 |
|
| 26 |
+
# ---------- Font ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def get_font(size=64):
|
| 28 |
+
if not os.path.exists(FONT_PATH):
|
| 29 |
+
import urllib.request
|
| 30 |
+
urllib.request.urlretrieve(FONT_URL, FONT_PATH)
|
| 31 |
+
return ImageFont.truetype(FONT_PATH, size)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
# ---------- Scrape ----------
|
| 34 |
def scrape_url(url):
|
| 35 |
try:
|
| 36 |
import trafilatura
|
| 37 |
text = trafilatura.extract(trafilatura.fetch_url(url))
|
| 38 |
return text.strip() if text else ""
|
| 39 |
except Exception as e:
|
| 40 |
+
print(f"Scrape error: {e}")
|
| 41 |
return ""
|
| 42 |
|
| 43 |
+
# ---------- Script Generation (Groq) ----------
|
| 44 |
+
def generate_script(content, groq_key, num_points=8):
|
| 45 |
from groq import Groq
|
| 46 |
client = Groq(api_key=groq_key.strip())
|
| 47 |
+
prompt = f"""Create a FACTUAL reel script based STRICTLY on the provided news article.
|
| 48 |
+
Do NOT invent facts. Each line: 15-20 words.
|
| 49 |
+
The LAST line MUST be a call-to-action with the source domain (e.g., 'Visit ChainStreet.io for the full story').
|
| 50 |
+
Output exactly {num_points} lines, one per line."""
|
| 51 |
resp = client.chat.completions.create(
|
| 52 |
model="llama-3.3-70b-versatile",
|
| 53 |
+
messages=[
|
| 54 |
+
{"role": "system", "content": prompt},
|
| 55 |
+
{"role": "user", "content": f"Content:\n{content[:4000]}"}
|
| 56 |
+
],
|
| 57 |
+
temperature=0.7,
|
| 58 |
max_tokens=700,
|
| 59 |
)
|
| 60 |
+
lines = [l.strip() for l in resp.choices[0].message.content.split("\n") if l.strip()]
|
| 61 |
return lines[:num_points]
|
| 62 |
|
| 63 |
+
# ---------- Edge TTS ----------
|
| 64 |
+
async def _tts(text, voice, path):
|
| 65 |
import edge_tts
|
| 66 |
await edge_tts.Communicate(text, voice).save(path)
|
| 67 |
|
| 68 |
def generate_audio(text, voice_key):
|
| 69 |
+
voice_id = EDGE_VOICES.get(voice_key, EDGE_VOICES[DEFAULT_VOICE])
|
|
|
|
|
|
|
|
|
|
| 70 |
out = tempfile.mktemp(suffix=".mp3")
|
| 71 |
loop = asyncio.new_event_loop()
|
| 72 |
try:
|
| 73 |
+
loop.run_until_complete(_tts(text, voice_id, out))
|
| 74 |
finally:
|
| 75 |
loop.close()
|
| 76 |
return out
|
| 77 |
|
| 78 |
+
# ---------- Pexels: fetch one portrait video ----------
|
| 79 |
def fetch_pexels_video(query, api_key):
|
| 80 |
if not api_key:
|
|
|
|
| 81 |
return None
|
| 82 |
try:
|
| 83 |
resp = requests.get(
|
| 84 |
"https://api.pexels.com/videos/search",
|
| 85 |
headers={"Authorization": api_key},
|
| 86 |
params={"query": query, "per_page": 5, "orientation": "portrait"},
|
| 87 |
+
timeout=10
|
| 88 |
)
|
| 89 |
data = resp.json()
|
| 90 |
videos = data.get("videos", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
for vid in videos:
|
| 92 |
+
for file in vid.get("video_files", []):
|
| 93 |
+
if file.get("height") == VIDEO_H and file.get("width") == VIDEO_W:
|
| 94 |
+
url = file["link"]
|
|
|
|
| 95 |
break
|
| 96 |
else:
|
| 97 |
# fallback to any portrait
|
| 98 |
+
for file in vid.get("video_files", []):
|
| 99 |
+
if file.get("width", 0) < file.get("height", 0):
|
| 100 |
+
url = file["link"]
|
| 101 |
break
|
| 102 |
else:
|
| 103 |
continue
|
|
|
|
| 107 |
with open(out, "wb") as f:
|
| 108 |
for chunk in r.iter_content(32768):
|
| 109 |
f.write(chunk)
|
|
|
|
| 110 |
return out
|
| 111 |
except Exception as e:
|
| 112 |
+
print(f"Pexels error: {e}")
|
| 113 |
return None
|
| 114 |
|
| 115 |
+
# ---------- Create final video (background loop + outro) ----------
|
| 116 |
+
def create_reel(sentences, audio_path, bg_video_path, logo_path, outro_path):
|
| 117 |
import moviepy.editor as mpe
|
| 118 |
+
from moviepy.video.fx import loop
|
| 119 |
|
| 120 |
W, H = VIDEO_W, VIDEO_H
|
|
|
|
| 121 |
audio = mpe.AudioFileClip(audio_path)
|
| 122 |
total_dur = audio.duration
|
| 123 |
+
seg_dur = total_dur / len(sentences)
|
| 124 |
|
| 125 |
+
# Background
|
| 126 |
if bg_video_path and os.path.exists(bg_video_path):
|
| 127 |
bg = mpe.VideoFileClip(bg_video_path)
|
| 128 |
+
# Resize to cover 720x1280
|
| 129 |
if bg.w / bg.h > W / H:
|
| 130 |
bg = bg.resize(height=H)
|
| 131 |
else:
|
|
|
|
| 134 |
if bg.duration < total_dur:
|
| 135 |
bg = mpe.concatenate_videoclips([bg] * int(np.ceil(total_dur / bg.duration)))
|
| 136 |
bg = bg.subclip(0, total_dur)
|
| 137 |
+
# darken for text readability
|
| 138 |
dark = mpe.ColorClip((W, H), color=(0,0,0)).set_opacity(0.3).set_duration(total_dur)
|
| 139 |
bg_layer = mpe.CompositeVideoClip([bg, dark])
|
| 140 |
else:
|
| 141 |
+
# gradient fallback
|
| 142 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
| 143 |
for i in range(H):
|
| 144 |
frame[i] = [int(30 + i/H*100), int(20 + i/H*50), int(40 + i/H*80)]
|
| 145 |
bg_layer = mpe.ImageClip(frame).set_duration(total_dur)
|
| 146 |
|
| 147 |
+
# Text clips (no background)
|
| 148 |
+
def make_text_clip(text, start, duration):
|
| 149 |
img = Image.new("RGBA", (W, H), (0,0,0,0))
|
| 150 |
draw = ImageDraw.Draw(img)
|
| 151 |
font = get_font(60)
|
| 152 |
+
# wrap
|
| 153 |
+
words = text.split()
|
| 154 |
lines = []
|
| 155 |
cur = []
|
| 156 |
for w in words:
|
|
|
|
| 168 |
for line in lines:
|
| 169 |
bbox = draw.textbbox((0,0), line, font=font)
|
| 170 |
x = (W - (bbox[2]-bbox[0]))//2
|
| 171 |
+
draw.text((x, y), line, font=font, fill=(255,255,255))
|
| 172 |
y += line_h
|
| 173 |
+
# logo overlay if provided
|
| 174 |
if logo_path and os.path.exists(logo_path):
|
| 175 |
try:
|
| 176 |
logo = Image.open(logo_path).convert("RGBA")
|
| 177 |
logo = logo.resize((180, int(180*logo.height/logo.width)), Image.LANCZOS)
|
| 178 |
img.paste(logo, (W-180-30, 30), logo)
|
| 179 |
except Exception as e:
|
| 180 |
+
print(f"Logo error: {e}")
|
| 181 |
+
return mpe.ImageClip(np.array(img)).set_start(start).set_duration(duration)
|
|
|
|
| 182 |
|
| 183 |
+
text_clips = [make_text_clip(s, i*seg_dur, seg_dur) for i, s in enumerate(sentences)]
|
| 184 |
final = mpe.CompositeVideoClip([bg_layer] + text_clips).set_audio(audio)
|
| 185 |
|
| 186 |
+
# Append outro
|
| 187 |
if outro_path and os.path.exists(outro_path):
|
| 188 |
outro = mpe.VideoFileClip(outro_path)
|
|
|
|
| 189 |
if outro.w / outro.h > W / H:
|
| 190 |
outro = outro.resize(height=H)
|
| 191 |
else:
|
|
|
|
| 194 |
final = mpe.concatenate_videoclips([final, outro])
|
| 195 |
|
| 196 |
out = tempfile.mktemp(suffix=".mp4")
|
| 197 |
+
final.write_videofile(out, codec="libx264", audio_codec="aac", fps=FPS, preset="medium", logger=None)
|
| 198 |
return out
|
| 199 |
|
| 200 |
+
# ---------- Gradio pipelines ----------
|
| 201 |
+
def generate_script_only(url_or_text, groq_key, num_points, progress=gr.Progress()):
|
| 202 |
if not groq_key:
|
| 203 |
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 204 |
if not groq_key:
|
| 205 |
+
return "", "❌ Groq API key required"
|
| 206 |
raw = url_or_text.strip()
|
| 207 |
if raw.startswith("http"):
|
| 208 |
content = scrape_url(raw)
|
| 209 |
if not content:
|
| 210 |
+
return "", "❌ Could not extract content from URL"
|
| 211 |
else:
|
| 212 |
content = raw
|
| 213 |
+
sentences = generate_script(content, groq_key, num_points)
|
|
|
|
|
|
|
| 214 |
if not sentences:
|
| 215 |
return "", "❌ Script generation failed"
|
| 216 |
script_text = "\n".join(sentences)
|
| 217 |
md = "\n\n".join(f"**{i+1}.** {s}" for i,s in enumerate(sentences))
|
| 218 |
return script_text, f"## Script Ready\n\n{md}"
|
| 219 |
|
| 220 |
+
def create_video_from_script(script_text, groq_key, pexels_key, voice_key,
|
| 221 |
+
logo_file, outro_file, progress=gr.Progress()):
|
| 222 |
if not groq_key:
|
| 223 |
groq_key = os.getenv("GROQ_API_KEY", "")
|
| 224 |
if not groq_key:
|
|
|
|
| 230 |
if not sentences:
|
| 231 |
return None, "❌ No script"
|
| 232 |
full_script = " ".join(sentences)
|
| 233 |
+
# generate audio
|
| 234 |
+
audio_path = generate_audio(full_script, voice_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
+
# fetch background video from Pexels
|
| 237 |
bg_video = None
|
| 238 |
if pexels_key:
|
| 239 |
+
# simple keyword from first sentence
|
| 240 |
kw = re.sub(r'[^\w\s]', '', sentences[0]).split()[:3]
|
| 241 |
kw = " ".join(kw) if kw else "news"
|
|
|
|
| 242 |
bg_video = fetch_pexels_video(kw, pexels_key)
|
|
|
|
|
|
|
| 243 |
|
| 244 |
logo_path = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
|
| 245 |
+
outro_path = outro_file if isinstance(outro_file, str) else (outro_file.name if outro_file else None)
|
| 246 |
|
| 247 |
+
video_path = create_reel(sentences, audio_path, bg_video, logo_path, outro_path)
|
| 248 |
md = "\n\n".join(f"**{i+1}.** {s}" for i,s in enumerate(sentences))
|
| 249 |
return video_path, f"## Reel Ready!\n\n{md}"
|
| 250 |
|
| 251 |
+
# ---------- UI ----------
|
| 252 |
with gr.Blocks(title="AI Reels Maker") as demo:
|
| 253 |
+
gr.Markdown("# 🎬 AI Reels Maker\nFactual news reels with Pexels background + optional outro")
|
| 254 |
+
with gr.Row():
|
| 255 |
+
with gr.Column():
|
| 256 |
+
url_input = gr.Textbox(label="URL or Topic", lines=3)
|
| 257 |
+
groq_key = gr.Textbox(label="Groq API Key", type="password", placeholder="or set GROQ_API_KEY secret")
|
| 258 |
+
pexels_key = gr.Textbox(label="Pexels API Key", type="password", placeholder="or set PEXELS_API_KEY secret")
|
| 259 |
+
voice = gr.Dropdown(choices=list(EDGE_VOICES.keys()), value=DEFAULT_VOICE, label="Voice")
|
| 260 |
+
logo = gr.File(label="Logo (optional)", type="filepath")
|
| 261 |
+
outro = gr.File(label="Outro Video (optional)", type="filepath")
|
| 262 |
+
num_points = gr.Slider(6, 10, value=8, step=1, label="Number of sentences")
|
| 263 |
+
gen_btn = gr.Button("Generate Script")
|
| 264 |
+
create_btn = gr.Button("Create Video")
|
| 265 |
+
with gr.Column():
|
| 266 |
+
script_editor = gr.TextArea(label="Edit Script", lines=12, interactive=True)
|
| 267 |
+
video = gr.Video(label="Your Reel")
|
| 268 |
+
status = gr.Markdown()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
+
gen_btn.click(generate_script_only, inputs=[url_input, groq_key, num_points], outputs=[script_editor, status])
|
| 271 |
+
create_btn.click(create_video_from_script, inputs=[script_editor, groq_key, pexels_key, voice, logo, outro], outputs=[video, status])
|
|
|
|
| 272 |
|
| 273 |
demo.launch()
|