Update app.py
Browse files
app.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
-
AI Reels Maker β HuggingFace Space
|
| 3 |
=========================================
|
| 4 |
Professional viral reel generator with:
|
| 5 |
-
β’ 45-60 second reels (8+ sentences,
|
| 6 |
β’ 2-3 stitched background videos from Pexels for variety
|
|
|
|
|
|
|
| 7 |
β’ Accurate voice selection β chosen voice is always used
|
| 8 |
-
β’ Clean
|
| 9 |
β’ Robust async TTS handling
|
| 10 |
-
β’ show_api=False to prevent gradio_client schema crash
|
| 11 |
"""
|
|
|
|
| 12 |
# ββ Unbuffered stdout so container logs appear in real-time ββββββββββββββββββ
|
| 13 |
import sys
|
| 14 |
sys.stdout.reconfigure(line_buffering=True)
|
|
@@ -42,8 +44,7 @@ FONT_BOLD_PATH = "/tmp/Montserrat-Bold.ttf"
|
|
| 42 |
FONT_LIGHT_PATH = "/tmp/Montserrat-Regular.ttf"
|
| 43 |
|
| 44 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
-
# VOICE CONFIGS
|
| 46 |
-
# Keys use plain ASCII β no emoji, no double-spaces β so Gradio never mismatches
|
| 47 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
EDGE_VOICES = {
|
| 49 |
"Aria (US Female)" : "en-US-AriaNeural",
|
|
@@ -71,9 +72,7 @@ KOKORO_VOICES = {
|
|
| 71 |
}
|
| 72 |
|
| 73 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
-
# REEL TYPES & PROMPT TEMPLATES
|
| 75 |
-
# Each prompt targets 15-20 words per sentence β ~58s audio at 150 wpm TTS
|
| 76 |
-
# Deliberately NO "Breaking News" framing unless user asks for it
|
| 77 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
REEL_TYPES = [
|
| 79 |
"Top 5", "Fact", "Ranking", "Step by Step Guide",
|
|
@@ -83,69 +82,77 @@ REEL_TYPES = [
|
|
| 83 |
|
| 84 |
REEL_PROMPTS = {
|
| 85 |
"Top 5": (
|
| 86 |
-
"Create a viral 'Top 5' reel script
|
| 87 |
-
"
|
| 88 |
-
"
|
| 89 |
-
"
|
|
|
|
| 90 |
),
|
| 91 |
"Fact": (
|
| 92 |
-
"Create a
|
| 93 |
-
"
|
| 94 |
-
"
|
|
|
|
|
|
|
| 95 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 96 |
),
|
| 97 |
"Ranking": (
|
| 98 |
-
"Create a RANKING reel script
|
| 99 |
-
"
|
| 100 |
-
"
|
|
|
|
| 101 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 102 |
),
|
| 103 |
"Step by Step Guide": (
|
| 104 |
-
"Create a STEP-BY-STEP GUIDE reel script
|
| 105 |
-
"Hook first, then clear numbered steps (Step 1: β¦, Step 2: β¦
|
| 106 |
-
"Every line: 15-20 words
|
| 107 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 108 |
),
|
| 109 |
"Statistics": (
|
| 110 |
-
"Create a STATISTICS reel script with
|
| 111 |
-
"Lead with the most
|
| 112 |
-
"Every line: 15-20 words, include a specific number
|
|
|
|
| 113 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 114 |
),
|
| 115 |
"Quiz": (
|
| 116 |
-
"Create an interactive QUIZ reel script
|
| 117 |
-
"Open with 'Can you
|
| 118 |
-
"Close the last line with a CTA
|
| 119 |
"Every line: 15-20 words. Output ONLY {n} lines, one per line. No extra text."
|
| 120 |
),
|
| 121 |
"Famous Quotes": (
|
| 122 |
-
"Create a FAMOUS QUOTES reel script related to the
|
| 123 |
-
"Each line is
|
| 124 |
-
"
|
| 125 |
"Output ONLY {n} quote lines, one per line. No extra text."
|
| 126 |
),
|
| 127 |
"Product Demo": (
|
| 128 |
-
"Create a PRODUCT/IDEA DEMO reel script
|
| 129 |
"Hook with the core problem, then explain the solution step by step.\n"
|
| 130 |
-
"Close with a
|
| 131 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 132 |
),
|
| 133 |
"Joke": (
|
| 134 |
-
"Create a COMEDY reel script
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 138 |
),
|
| 139 |
"Blog to Reel": (
|
| 140 |
-
"Distill
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"Output ONLY {n} lines, one per line.
|
| 144 |
),
|
| 145 |
"Custom Prompt": (
|
| 146 |
-
"You are a
|
| 147 |
-
"
|
| 148 |
-
"
|
|
|
|
|
|
|
|
|
|
| 149 |
),
|
| 150 |
}
|
| 151 |
|
|
@@ -190,7 +197,7 @@ def get_font(size: int = 64, bold: bool = True) -> ImageFont.FreeTypeFont:
|
|
| 190 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 191 |
def scrape_url(url: str) -> str:
|
| 192 |
try:
|
| 193 |
-
import trafilatura
|
| 194 |
dl = trafilatura.fetch_url(url)
|
| 195 |
text = trafilatura.extract(dl, include_tables=False,
|
| 196 |
include_comments=False, favor_recall=True)
|
|
@@ -200,7 +207,7 @@ def scrape_url(url: str) -> str:
|
|
| 200 |
return ""
|
| 201 |
|
| 202 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
-
# SCRIPT GENERATION
|
| 204 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
def generate_script(
|
| 206 |
content: str,
|
|
@@ -208,7 +215,7 @@ def generate_script(
|
|
| 208 |
reel_type: str = "Fact",
|
| 209 |
num_points: int = 8,
|
| 210 |
) -> list[str]:
|
| 211 |
-
from groq import Groq
|
| 212 |
client = Groq(api_key=groq_key.strip())
|
| 213 |
template = REEL_PROMPTS.get(reel_type, REEL_PROMPTS["Custom Prompt"])
|
| 214 |
system = template.format(n=num_points)
|
|
@@ -223,27 +230,21 @@ def generate_script(
|
|
| 223 |
max_tokens=700,
|
| 224 |
)
|
| 225 |
raw = resp.choices[0].message.content.strip()
|
| 226 |
-
# Strip numbering / bullets the LLM sometimes adds
|
| 227 |
lines = [
|
| 228 |
re.sub(r"^[\d]+[.)]\s*|^[-β’*]\s*|^Step\s+\d+:\s*", "", l).strip()
|
| 229 |
for l in raw.splitlines() if l.strip()
|
| 230 |
]
|
| 231 |
-
lines = [l for l in lines if l]
|
| 232 |
return lines[:num_points]
|
| 233 |
|
| 234 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 235 |
-
# AUDIO β Edge TTS
|
| 236 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
async def _edge_tts_save(text: str, voice: str, path: str) -> None:
|
| 238 |
-
import edge_tts
|
| 239 |
await edge_tts.Communicate(text, voice).save(path)
|
| 240 |
|
| 241 |
def generate_audio_edge(text: str, voice_key: str) -> str:
|
| 242 |
-
"""
|
| 243 |
-
Generate MP3 with Edge TTS.
|
| 244 |
-
voice_key must be a key from EDGE_VOICES (e.g. "Aria (US Female)").
|
| 245 |
-
Falls back to Aria only if the key is genuinely missing.
|
| 246 |
-
"""
|
| 247 |
voice = EDGE_VOICES.get(voice_key)
|
| 248 |
if voice is None:
|
| 249 |
print(f"[TTS] β Unknown voice key {voice_key!r} β using default", flush=True)
|
|
@@ -251,7 +252,6 @@ def generate_audio_edge(text: str, voice_key: str) -> str:
|
|
| 251 |
print(f"[TTS] Using voice: {voice} (key={voice_key!r})", flush=True)
|
| 252 |
|
| 253 |
out = tempfile.mktemp(suffix=".mp3")
|
| 254 |
-
# Always create a fresh event loop to avoid conflicts with Gradio's loop
|
| 255 |
loop = asyncio.new_event_loop()
|
| 256 |
try:
|
| 257 |
loop.run_until_complete(_edge_tts_save(text, voice, out))
|
|
@@ -260,7 +260,7 @@ def generate_audio_edge(text: str, voice_key: str) -> str:
|
|
| 260 |
return out
|
| 261 |
|
| 262 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 263 |
-
# AUDIO β Kokoro TTS
|
| 264 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 265 |
_kokoro_pipeline = None
|
| 266 |
_kokoro_available = None
|
|
@@ -269,16 +269,16 @@ def _check_kokoro() -> bool:
|
|
| 269 |
global _kokoro_available
|
| 270 |
if _kokoro_available is None:
|
| 271 |
try:
|
| 272 |
-
import kokoro
|
| 273 |
_kokoro_available = True
|
| 274 |
except ImportError:
|
| 275 |
_kokoro_available = False
|
| 276 |
return _kokoro_available
|
| 277 |
|
| 278 |
def generate_audio_kokoro(text: str, voice_key: str) -> str:
|
| 279 |
-
import soundfile as sf
|
| 280 |
if not _check_kokoro():
|
| 281 |
-
raise ImportError("Kokoro TTS not installed
|
| 282 |
global _kokoro_pipeline
|
| 283 |
if _kokoro_pipeline is None:
|
| 284 |
from kokoro import KPipeline
|
|
@@ -290,7 +290,7 @@ def generate_audio_kokoro(text: str, voice_key: str) -> str:
|
|
| 290 |
return out
|
| 291 |
|
| 292 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
-
# PEXELS β fetch
|
| 294 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 295 |
_STOP = frozenset({
|
| 296 |
"the","a","an","is","are","was","were","be","been","have","has","had","do","does","did",
|
|
@@ -303,31 +303,26 @@ _STOP = frozenset({
|
|
| 303 |
})
|
| 304 |
|
| 305 |
def _extract_queries(sentences: list[str], count: int = 3) -> list[str]:
|
| 306 |
-
"""Build `count` diverse Pexels search queries from the script sentences."""
|
| 307 |
text = " ".join(sentences)
|
| 308 |
words = re.sub(r"[^a-zA-Z\s]", "", text).lower().split()
|
| 309 |
kws = [w for w in words if w not in _STOP and len(w) > 3]
|
| 310 |
-
# Deduplicate while preserving order
|
| 311 |
seen, uniq = set(), []
|
| 312 |
for w in kws:
|
| 313 |
if w not in seen:
|
| 314 |
seen.add(w)
|
| 315 |
uniq.append(w)
|
| 316 |
-
|
| 317 |
-
n = max(1, len(uniq) // count)
|
| 318 |
queries = []
|
| 319 |
for i in range(count):
|
| 320 |
bucket = uniq[i * n : i * n + 3]
|
| 321 |
if bucket:
|
| 322 |
queries.append(" ".join(bucket))
|
| 323 |
-
# Pad with generic fallbacks if needed
|
| 324 |
fallbacks = ["nature landscape", "city streets night", "technology abstract"]
|
| 325 |
while len(queries) < count:
|
| 326 |
queries.append(fallbacks[len(queries) % len(fallbacks)])
|
| 327 |
return queries[:count]
|
| 328 |
|
| 329 |
def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
| 330 |
-
"""Download one Pexels portrait video not already in used_ids."""
|
| 331 |
try:
|
| 332 |
resp = requests.get(
|
| 333 |
"https://api.pexels.com/videos/search",
|
|
@@ -341,10 +336,7 @@ def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
|
| 341 |
if vid_id in used_ids:
|
| 342 |
continue
|
| 343 |
files = vid.get("video_files", [])
|
| 344 |
-
portrait = [
|
| 345 |
-
f for f in files
|
| 346 |
-
if f.get("width", 9999) < f.get("height", 0) and f.get("width", 0) >= 360
|
| 347 |
-
]
|
| 348 |
candidates = sorted(portrait or files, key=lambda x: x.get("width", 0), reverse=True)
|
| 349 |
if not candidates:
|
| 350 |
continue
|
|
@@ -363,7 +355,6 @@ def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
|
| 363 |
return None
|
| 364 |
|
| 365 |
def fetch_bg_videos(sentences: list[str], api_key: str, count: int = 3) -> list[str]:
|
| 366 |
-
"""Return up to `count` distinct background video paths."""
|
| 367 |
queries = _extract_queries(sentences, count=count)
|
| 368 |
used_ids = set()
|
| 369 |
paths = []
|
|
@@ -418,7 +409,6 @@ def render_text_frame(
|
|
| 418 |
draw = ImageDraw.Draw(img)
|
| 419 |
font = get_font(60, bold=True)
|
| 420 |
|
| 421 |
-
# Wrap text to fit 84% of frame width
|
| 422 |
words, lines, cur = text.split(), [], []
|
| 423 |
for word in words:
|
| 424 |
test = " ".join(cur + [word])
|
|
@@ -437,7 +427,6 @@ def render_text_frame(
|
|
| 437 |
box_y1 = max(pad_h, (height - total_text_h) // 2 - pad_v)
|
| 438 |
box_y2 = min(height - pad_h, box_y1 + total_text_h + pad_v * 2)
|
| 439 |
|
| 440 |
-
# Semi-transparent card + left accent bar
|
| 441 |
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
| 442 |
od = ImageDraw.Draw(overlay)
|
| 443 |
od.rounded_rectangle([20, box_y1, width - 20, box_y2], radius=22, fill=(6, 6, 20, 190))
|
|
@@ -445,7 +434,6 @@ def render_text_frame(
|
|
| 445 |
img = Image.alpha_composite(img, overlay)
|
| 446 |
draw = ImageDraw.Draw(img)
|
| 447 |
|
| 448 |
-
# Draw text with drop-shadow
|
| 449 |
y = box_y1 + pad_v
|
| 450 |
for line in lines:
|
| 451 |
bbox = draw.textbbox((0, 0), line, font=font)
|
|
@@ -461,10 +449,9 @@ def render_text_frame(
|
|
| 461 |
return np.array(img)
|
| 462 |
|
| 463 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 464 |
-
# VIDEO ASSEMBLY
|
| 465 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 466 |
def _fit_bg(clip, W: int, H: int):
|
| 467 |
-
"""Resize + center-crop a clip to exactly WΓH."""
|
| 468 |
if clip.w / clip.h > W / H:
|
| 469 |
clip = clip.resize(height=H)
|
| 470 |
else:
|
|
@@ -476,49 +463,43 @@ def _fit_bg(clip, W: int, H: int):
|
|
| 476 |
return clip
|
| 477 |
|
| 478 |
def _gradient_clip(W: int, H: int, accent_color: tuple, duration: float):
|
| 479 |
-
"""Fallback animated gradient when no Pexels video is available."""
|
| 480 |
import moviepy.editor as mpe
|
| 481 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
| 482 |
r0, g0, b0 = (max(0, accent_color[0] - 60),
|
| 483 |
max(0, accent_color[1] - 60),
|
| 484 |
max(0, accent_color[2] - 80))
|
| 485 |
for i in range(H):
|
| 486 |
-
t
|
| 487 |
frame[i] = [int(r0 + t * 60), int(g0 + t * 20), int(b0 + t * 80)]
|
| 488 |
return mpe.ImageClip(frame).set_duration(duration)
|
| 489 |
|
| 490 |
def create_reel(
|
| 491 |
sentences: list[str],
|
| 492 |
audio_path: str,
|
| 493 |
-
bg_video_paths: list[str],
|
| 494 |
logo_path: str | None = None,
|
| 495 |
logo_pos: str = "top-right",
|
| 496 |
accent_hex: str = "#7c3aed",
|
| 497 |
) -> str:
|
| 498 |
-
import moviepy.editor as mpe
|
| 499 |
-
|
| 500 |
W, H = VIDEO_W, VIDEO_H
|
| 501 |
|
| 502 |
try:
|
| 503 |
-
h
|
| 504 |
-
accent_color = tuple(int(h[i:i
|
| 505 |
except Exception:
|
| 506 |
accent_color = (124, 58, 237)
|
| 507 |
|
| 508 |
-
logo
|
| 509 |
-
audio
|
| 510 |
total_dur = audio.duration
|
| 511 |
-
n_sents
|
| 512 |
-
dur_each
|
| 513 |
|
| 514 |
-
print(
|
| 515 |
-
f"[reel] duration={total_dur:.1f}s | {n_sents} sentences | "
|
| 516 |
-
f"{dur_each:.1f}s each | {len(bg_video_paths)} bg clips",
|
| 517 |
-
flush=True,
|
| 518 |
-
)
|
| 519 |
|
| 520 |
-
#
|
| 521 |
-
num_bg
|
| 522 |
seg_dur = total_dur / num_bg
|
| 523 |
bg_segs = []
|
| 524 |
|
|
@@ -528,54 +509,41 @@ def create_reel(
|
|
| 528 |
try:
|
| 529 |
bg = mpe.VideoFileClip(bg_video_paths[i], audio=False)
|
| 530 |
bg = _fit_bg(bg, W, H)
|
| 531 |
-
# Loop the clip if shorter than its segment
|
| 532 |
if bg.duration < seg_dur:
|
| 533 |
loops = int(np.ceil(seg_dur / bg.duration)) + 1
|
| 534 |
-
bg
|
| 535 |
bg = bg.subclip(0, seg_dur)
|
| 536 |
-
# Darken to keep text legible
|
| 537 |
dark = mpe.ColorClip((W, H), color=[0, 0, 0]).set_opacity(0.42).set_duration(seg_dur)
|
| 538 |
-
seg
|
| 539 |
bg_segs.append(seg)
|
| 540 |
continue
|
| 541 |
except Exception as e:
|
| 542 |
print(f"[reel] bg clip {i} failed ({e}), using gradient", flush=True)
|
| 543 |
-
# Gradient fallback
|
| 544 |
grad = _gradient_clip(W, H, accent_color, seg_dur).set_start(start)
|
| 545 |
bg_segs.append(grad)
|
| 546 |
|
| 547 |
bg_layer = mpe.CompositeVideoClip(bg_segs, size=(W, H)).set_duration(total_dur)
|
| 548 |
|
| 549 |
-
#
|
| 550 |
text_clips = []
|
| 551 |
for i, sentence in enumerate(sentences):
|
| 552 |
arr = render_text_frame(sentence, W, H, logo, logo_pos, accent_color)
|
| 553 |
-
tc
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
.crossfadeout(0.15)
|
| 559 |
-
)
|
| 560 |
text_clips.append(tc)
|
| 561 |
|
| 562 |
-
# ββ Compose and render ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 563 |
final = mpe.CompositeVideoClip([bg_layer] + text_clips).set_audio(audio)
|
| 564 |
-
out
|
| 565 |
-
final.write_videofile(
|
| 566 |
-
|
| 567 |
-
codec="libx264",
|
| 568 |
-
audio_codec="aac",
|
| 569 |
-
fps=FPS,
|
| 570 |
-
preset="ultrafast",
|
| 571 |
-
threads=4,
|
| 572 |
-
logger=None,
|
| 573 |
-
)
|
| 574 |
print(f"[reel] β Written to {out}", flush=True)
|
| 575 |
return out
|
| 576 |
|
| 577 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 578 |
-
# NEWS ANCHOR MODE
|
| 579 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 580 |
def _make_studio_bg(W: int, H: int, dark: bool, accent_color: tuple) -> np.ndarray:
|
| 581 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
|
@@ -588,8 +556,7 @@ def _make_studio_bg(W: int, H: int, dark: bool, accent_color: tuple) -> np.ndarr
|
|
| 588 |
ow, oh = 400, 400
|
| 589 |
ox, oy = W // 2 - ow // 2, H // 2 - oh // 2
|
| 590 |
draw.ellipse([ox, oy, ox + ow, oy + oh], outline=(*accent_color, 60), width=3)
|
| 591 |
-
draw.ellipse([ox + 50, oy + 50, ox + ow - 50, oy + oh - 50],
|
| 592 |
-
outline=(*accent_color, 28), width=2)
|
| 593 |
draw.rectangle([0, H - 290, W, H - 240], fill=accent_color)
|
| 594 |
return np.array(img)
|
| 595 |
|
|
@@ -598,20 +565,17 @@ def _draw_lower_third(draw, font_bold, font_light, name, title, W, H, accent_col
|
|
| 598 |
bar_y = H - 235
|
| 599 |
draw.rectangle([0, bar_y, W, bar_y + bar_h], fill=(*accent_color, 220))
|
| 600 |
if name:
|
| 601 |
-
draw.text((32, bar_y + 8),
|
| 602 |
if title:
|
| 603 |
-
draw.text((32, bar_y + 50), title,
|
| 604 |
|
| 605 |
def _draw_ticker(draw, font, ticker_text, W, H, frame_num, scroll_speed=4):
|
| 606 |
ticker_h = 50
|
| 607 |
-
bar_y
|
| 608 |
draw.rectangle([0, bar_y, W, H], fill=(18, 18, 18, 235))
|
| 609 |
offset = W - (frame_num * scroll_speed % (W + len(ticker_text) * 14))
|
| 610 |
-
draw.text(
|
| 611 |
-
|
| 612 |
-
f" β {ticker_text} β {ticker_text} β {ticker_text}",
|
| 613 |
-
font=font, fill=(255, 215, 0, 255),
|
| 614 |
-
)
|
| 615 |
|
| 616 |
def process_anchor_video(
|
| 617 |
anchor_video_path: str,
|
|
@@ -626,28 +590,25 @@ def process_anchor_video(
|
|
| 626 |
progress,
|
| 627 |
) -> str:
|
| 628 |
import moviepy.editor as mpe
|
| 629 |
-
|
| 630 |
try:
|
| 631 |
-
h
|
| 632 |
-
accent_color = tuple(int(h[i:i
|
| 633 |
except Exception:
|
| 634 |
accent_color = (5, 38, 120)
|
| 635 |
|
| 636 |
progress(0.10, desc="πΉ Loading anchor videoβ¦")
|
| 637 |
clip = mpe.VideoFileClip(anchor_video_path)
|
| 638 |
W, H = VIDEO_W, VIDEO_H
|
| 639 |
-
dur
|
| 640 |
|
| 641 |
-
font_bold
|
| 642 |
-
font_light
|
| 643 |
ticker_font = get_font(24, bold=False)
|
| 644 |
-
logo
|
| 645 |
|
| 646 |
progress(0.22, desc="π¨ Preparing backgroundβ¦")
|
| 647 |
if bg_choice == "Blur original":
|
| 648 |
-
bg_clip = clip.fl_image(
|
| 649 |
-
lambda f: np.array(Image.fromarray(f).filter(ImageFilter.GaussianBlur(radius=18)))
|
| 650 |
-
)
|
| 651 |
bg_clip = _fit_bg(bg_clip, W, H)
|
| 652 |
elif bg_choice == "News studio (dark)":
|
| 653 |
sf = _make_studio_bg(W, H, dark=True, accent_color=accent_color)
|
|
@@ -657,21 +618,21 @@ def process_anchor_video(
|
|
| 657 |
bg_clip = mpe.ImageClip(sf).set_duration(dur)
|
| 658 |
elif bg_choice == "Pexels" and pexels_key.strip():
|
| 659 |
kw = news_topic or "news studio broadcast"
|
| 660 |
-
p
|
| 661 |
if p:
|
| 662 |
pex = mpe.VideoFileClip(p, audio=False)
|
| 663 |
pex = _fit_bg(pex, W, H)
|
| 664 |
if pex.duration < dur:
|
| 665 |
loops = int(np.ceil(dur / pex.duration)) + 1
|
| 666 |
-
pex
|
| 667 |
bg_clip = pex.subclip(0, dur)
|
| 668 |
else:
|
| 669 |
sf = _make_studio_bg(W, H, dark=True, accent_color=accent_color)
|
| 670 |
bg_clip = mpe.ImageClip(sf).set_duration(dur)
|
| 671 |
else:
|
| 672 |
-
frame
|
| 673 |
frame[:, :] = accent_color
|
| 674 |
-
bg_clip
|
| 675 |
|
| 676 |
progress(0.45, desc="βοΈ Compositing anchorβ¦")
|
| 677 |
anchor_w = int(W * 0.72)
|
|
@@ -682,10 +643,9 @@ def process_anchor_video(
|
|
| 682 |
anchor_clip = clip.resize(width=anchor_w).set_position(((W - anchor_w) // 2, int(H * 0.08)))
|
| 683 |
|
| 684 |
progress(0.60, desc="πΌοΈ Adding news graphicsβ¦")
|
| 685 |
-
|
| 686 |
def add_overlay(get_frame, t):
|
| 687 |
frame = get_frame(t)
|
| 688 |
-
img
|
| 689 |
if logo:
|
| 690 |
img = paste_logo(img, logo, "top-left")
|
| 691 |
draw = ImageDraw.Draw(img)
|
|
@@ -698,126 +658,154 @@ def process_anchor_video(
|
|
| 698 |
return np.array(img.convert("RGB"))
|
| 699 |
|
| 700 |
composite = mpe.CompositeVideoClip([bg_clip.set_duration(dur), anchor_clip], size=(W, H))
|
| 701 |
-
final
|
| 702 |
if clip.audio:
|
| 703 |
final = final.set_audio(clip.audio)
|
| 704 |
|
| 705 |
progress(0.80, desc="ποΈ Rendering final videoβ¦")
|
| 706 |
out = tempfile.mktemp(suffix=".mp4")
|
| 707 |
-
final.write_videofile(
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
preset="ultrafast", threads=4, logger=None,
|
| 711 |
-
)
|
| 712 |
return out
|
| 713 |
|
| 714 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 715 |
-
# MAIN
|
| 716 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 717 |
-
def
|
| 718 |
-
url_or_text,
|
| 719 |
-
groq_key,
|
| 720 |
-
pexels_key,
|
| 721 |
-
reel_type,
|
| 722 |
-
tts_engine,
|
| 723 |
-
edge_voice,
|
| 724 |
-
kokoro_voice,
|
| 725 |
-
num_points,
|
| 726 |
-
logo_file,
|
| 727 |
-
logo_pos,
|
| 728 |
-
accent_hex,
|
| 729 |
-
progress=gr.Progress(track_tqdm=True),
|
| 730 |
):
|
| 731 |
try:
|
| 732 |
if not groq_key.strip():
|
| 733 |
-
return
|
| 734 |
|
| 735 |
progress(0.05, desc="π Fetching contentβ¦")
|
| 736 |
raw = url_or_text.strip()
|
| 737 |
if raw.startswith("http"):
|
| 738 |
content = scrape_url(raw)
|
| 739 |
if not content or len(content) < 60:
|
| 740 |
-
return
|
| 741 |
-
"β Could not extract usable text from that URL.\n\n"
|
| 742 |
-
"Try pasting the article text directly instead."
|
| 743 |
-
)
|
| 744 |
else:
|
| 745 |
content = raw
|
| 746 |
-
if len(content) <
|
| 747 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
|
|
|
| 752 |
if not sentences:
|
| 753 |
-
return None, "β Script
|
| 754 |
|
| 755 |
full_script = " ".join(sentences)
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
print(
|
| 759 |
-
f"[pipeline] {len(sentences)} sentences | {word_count} words | ~{est_secs}s audio",
|
| 760 |
-
flush=True,
|
| 761 |
-
)
|
| 762 |
|
| 763 |
-
# Decide TTS engine
|
| 764 |
using_kokoro = tts_engine == "Kokoro TTS" and _check_kokoro()
|
| 765 |
-
|
| 766 |
-
progress(0.35, desc=f"ποΈ Generating voice β {voice_label}β¦")
|
| 767 |
-
|
| 768 |
if using_kokoro:
|
| 769 |
try:
|
| 770 |
audio_path = generate_audio_kokoro(full_script, kokoro_voice)
|
| 771 |
except Exception as e:
|
| 772 |
-
print(f"[kokoro fallback] {e}"
|
| 773 |
audio_path = generate_audio_edge(full_script, edge_voice)
|
| 774 |
-
voice_label = edge_voice
|
| 775 |
else:
|
| 776 |
audio_path = generate_audio_edge(full_script, edge_voice)
|
| 777 |
|
| 778 |
-
# Fetch background videos (2-3 diverse clips)
|
| 779 |
bg_videos = []
|
| 780 |
if pexels_key.strip():
|
| 781 |
-
progress(0.
|
| 782 |
bg_videos = fetch_bg_videos(sentences, pexels_key, count=3)
|
| 783 |
|
| 784 |
-
progress(0.75, desc="ποΈ Assembling reelβ¦")
|
| 785 |
-
logo_path
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
|
| 797 |
except Exception as e:
|
| 798 |
import traceback
|
| 799 |
-
return None, f"β **Error:** {e}\n\n```\n{traceback.format_exc()}\n```"
|
| 800 |
|
| 801 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 802 |
-
# ANCHOR PIPELINE
|
| 803 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 804 |
def anchor_pipeline(
|
| 805 |
-
anchor_video,
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
anchor_name,
|
| 810 |
-
anchor_title,
|
| 811 |
-
ticker_text,
|
| 812 |
-
logo_file,
|
| 813 |
-
accent_hex,
|
| 814 |
-
progress=gr.Progress(track_tqdm=True),
|
| 815 |
):
|
| 816 |
try:
|
| 817 |
if anchor_video is None:
|
| 818 |
return None, "β Please upload your anchor video first."
|
| 819 |
video_path = anchor_video if isinstance(anchor_video, str) else anchor_video.name
|
| 820 |
-
logo_path
|
| 821 |
out = process_anchor_video(
|
| 822 |
video_path, bg_choice, pexels_key,
|
| 823 |
news_topic, anchor_name, anchor_title, ticker_text,
|
|
@@ -826,7 +814,7 @@ def anchor_pipeline(
|
|
| 826 |
return out, "## β
News Anchor Reel Ready!"
|
| 827 |
except Exception as e:
|
| 828 |
import traceback
|
| 829 |
-
return None, f"β **Error:** {e}\n\n```\n{traceback.format_exc()}\n```"
|
| 830 |
|
| 831 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 832 |
# GRADIO UI
|
|
@@ -839,7 +827,7 @@ footer { display: none !important; }
|
|
| 839 |
border: none !important; color: #fff !important; }
|
| 840 |
"""
|
| 841 |
|
| 842 |
-
with gr.Blocks(title="π¬ AI Reels Maker") as demo:
|
| 843 |
|
| 844 |
gr.Markdown(
|
| 845 |
"# π¬ AI Reels Maker\n"
|
|
@@ -847,16 +835,15 @@ with gr.Blocks(title="π¬ AI Reels Maker") as demo:
|
|
| 847 |
)
|
| 848 |
|
| 849 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 850 |
-
# TAB 1 β Reel Generator
|
| 851 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 852 |
with gr.Tab("π¬ Reel Generator"):
|
| 853 |
with gr.Row():
|
| 854 |
|
| 855 |
-
# ββ Left: inputs βββββββββββββββββββββββββββββββββββββββββββββ
|
| 856 |
with gr.Column(scale=1):
|
| 857 |
url_input = gr.Textbox(
|
| 858 |
label="π URL or Topic",
|
| 859 |
-
placeholder="Paste a news article
|
| 860 |
lines=3,
|
| 861 |
)
|
| 862 |
reel_type = gr.Dropdown(
|
|
@@ -867,77 +854,48 @@ with gr.Blocks(title="π¬ AI Reels Maker") as demo:
|
|
| 867 |
|
| 868 |
with gr.Accordion("βοΈ API Keys", open=True):
|
| 869 |
with gr.Row():
|
| 870 |
-
groq_key = gr.Textbox(
|
| 871 |
-
|
| 872 |
-
type="password",
|
| 873 |
-
placeholder="gsk_β¦ (free at console.groq.com)",
|
| 874 |
-
)
|
| 875 |
-
pexels_key = gr.Textbox(
|
| 876 |
-
label="π₯ Pexels API Key",
|
| 877 |
-
type="password",
|
| 878 |
-
placeholder="Optional β fetches 2-3 background videos",
|
| 879 |
-
)
|
| 880 |
|
| 881 |
with gr.Accordion("ποΈ Voice", open=True):
|
| 882 |
tts_engine = gr.Radio(
|
| 883 |
choices=["Edge TTS", "Kokoro TTS"],
|
| 884 |
value="Edge TTS",
|
| 885 |
label="Voice Engine",
|
| 886 |
-
info=(
|
| 887 |
-
"Edge TTS β always available, 10 natural voices | "
|
| 888 |
-
"Kokoro TTS β open-source (auto-falls back to Edge TTS if not installed)"
|
| 889 |
-
),
|
| 890 |
)
|
| 891 |
with gr.Row():
|
| 892 |
-
edge_voice
|
| 893 |
-
|
| 894 |
-
value=DEFAULT_VOICE_KEY,
|
| 895 |
-
label="π€ Edge TTS Voice",
|
| 896 |
-
visible=True,
|
| 897 |
-
)
|
| 898 |
-
kokoro_voice = gr.Dropdown(
|
| 899 |
-
choices=list(KOKORO_VOICES.keys()),
|
| 900 |
-
value="Heart (US Female)",
|
| 901 |
-
label="π€ Kokoro Voice",
|
| 902 |
-
visible=False,
|
| 903 |
-
)
|
| 904 |
|
| 905 |
with gr.Accordion("π¨ Branding", open=False):
|
| 906 |
-
logo_file
|
| 907 |
-
logo_pos
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
)
|
| 912 |
-
accent_hex = gr.ColorPicker(value="#7c3aed", label="Accent Color")
|
| 913 |
|
| 914 |
-
num_points = gr.Slider(
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
info="More points = longer reel. 8 points β 55 seconds.",
|
| 918 |
-
)
|
| 919 |
-
gen_btn = gr.Button(
|
| 920 |
-
"π Generate Reel", variant="primary", size="lg", elem_classes=["gen-btn"]
|
| 921 |
-
)
|
| 922 |
|
| 923 |
-
# ββ Right: output βββββββββββββββββββββββββββββββββββββββββββββ
|
| 924 |
with gr.Column(scale=1):
|
|
|
|
| 925 |
video_out = gr.Video(label="π¬ Your Reel", height=560)
|
| 926 |
script_out = gr.Markdown()
|
| 927 |
|
| 928 |
-
# Toggle voice dropdowns based on engine selection
|
| 929 |
tts_engine.change(
|
| 930 |
-
|
| 931 |
-
inputs=tts_engine,
|
| 932 |
-
outputs=[edge_voice, kokoro_voice],
|
| 933 |
)
|
| 934 |
gen_btn.click(
|
| 935 |
-
|
| 936 |
-
inputs=[
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
|
|
|
|
|
|
| 941 |
outputs=[video_out, script_out],
|
| 942 |
)
|
| 943 |
|
|
@@ -946,74 +904,68 @@ with gr.Blocks(title="π¬ AI Reels Maker") as demo:
|
|
| 946 |
# βββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββ
|
| 947 |
with gr.Tab("πΊ News Anchor Mode"):
|
| 948 |
gr.Markdown(
|
| 949 |
-
"Upload your
|
| 950 |
-
"
|
|
|
|
|
|
|
|
|
|
| 951 |
)
|
| 952 |
with gr.Row():
|
| 953 |
with gr.Column(scale=1):
|
| 954 |
anchor_video = gr.Video(label="πΉ Upload Anchor Video (MP4/MOV)")
|
| 955 |
bg_choice = gr.Dropdown(
|
| 956 |
-
choices=[
|
| 957 |
-
"Blur original", "News studio (dark)",
|
| 958 |
-
"News studio (light)", "Pexels", "Solid color",
|
| 959 |
-
],
|
| 960 |
value="News studio (dark)",
|
| 961 |
label="π¨ Background Style",
|
| 962 |
)
|
| 963 |
-
a_pexels_key
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
)
|
| 968 |
-
news_topic = gr.Textbox(
|
| 969 |
-
label="π Pexels search keyword",
|
| 970 |
-
placeholder="e.g. 'news studio city'",
|
| 971 |
-
visible=False,
|
| 972 |
-
)
|
| 973 |
with gr.Accordion("πͺͺ Name & Title (Lower Third)", open=True):
|
| 974 |
anchor_name = gr.Textbox(label="Anchor Name", placeholder="Jane Doe")
|
| 975 |
anchor_title = gr.Textbox(label="Anchor Title", placeholder="Senior Correspondent")
|
|
|
|
| 976 |
ticker_text = gr.Textbox(
|
| 977 |
label="π° Ticker Text (scrolls at bottom)",
|
| 978 |
-
placeholder="Enter your headline hereβ¦ | More updates coming soonβ¦",
|
| 979 |
)
|
| 980 |
-
a_logo_file = gr.File(label="π€ Upload Channel Logo", file_types=["image"])
|
| 981 |
a_accent_hex = gr.ColorPicker(value="#052680", label="Accent / Brand Color")
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
with gr.Column(scale=1):
|
| 987 |
anchor_video_out = gr.Video(label="πΊ Processed Reel", height=560)
|
| 988 |
anchor_status = gr.Markdown()
|
| 989 |
|
| 990 |
bg_choice.change(
|
| 991 |
-
|
| 992 |
-
inputs=bg_choice,
|
| 993 |
-
outputs=news_topic,
|
| 994 |
)
|
| 995 |
anchor_btn.click(
|
| 996 |
-
|
| 997 |
-
inputs=[
|
| 998 |
-
|
| 999 |
-
anchor_name, anchor_title, ticker_text, a_logo_file, a_accent_hex,
|
| 1000 |
-
],
|
| 1001 |
outputs=[anchor_video_out, anchor_status],
|
| 1002 |
)
|
| 1003 |
|
| 1004 |
-
#
|
|
|
|
|
|
|
| 1005 |
gr.Markdown("""
|
| 1006 |
---
|
| 1007 |
-
### π Free API
|
| 1008 |
| Service | Purpose | Link |
|
| 1009 |
|---------|---------|------|
|
| 1010 |
| **Groq** *(required)* | AI script generation β Llama 3.3-70B | [console.groq.com](https://console.groq.com) |
|
| 1011 |
| **Pexels** *(optional)* | Free HD stock video backgrounds | [pexels.com/api](https://www.pexels.com/api/) |
|
| 1012 |
|
| 1013 |
-
**
|
|
|
|
| 1014 |
""")
|
| 1015 |
|
| 1016 |
-
print("β UI built, launching Gradio serverβ¦", flush=True)
|
| 1017 |
-
|
| 1018 |
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 1019 |
-
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
AI Reels Maker β HuggingFace Space v3.0
|
| 3 |
=========================================
|
| 4 |
Professional viral reel generator with:
|
| 5 |
+
β’ 45-60 second reels (8+ sentences, 15-20 words each)
|
| 6 |
β’ 2-3 stitched background videos from Pexels for variety
|
| 7 |
+
β’ Optional outro video (plays after the main reel)
|
| 8 |
+
β’ Editable script: generate, edit, then render
|
| 9 |
β’ Accurate voice selection β chosen voice is always used
|
| 10 |
+
β’ Clean, factual prompts that include a call-to-action
|
| 11 |
β’ Robust async TTS handling
|
|
|
|
| 12 |
"""
|
| 13 |
+
|
| 14 |
# ββ Unbuffered stdout so container logs appear in real-time ββββββββββββββββββ
|
| 15 |
import sys
|
| 16 |
sys.stdout.reconfigure(line_buffering=True)
|
|
|
|
| 44 |
FONT_LIGHT_PATH = "/tmp/Montserrat-Regular.ttf"
|
| 45 |
|
| 46 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
# VOICE CONFIGS (plain ASCII keys to avoid Gradio mismatches)
|
|
|
|
| 48 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
EDGE_VOICES = {
|
| 50 |
"Aria (US Female)" : "en-US-AriaNeural",
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
# REEL TYPES & PROMPT TEMPLATES (improved: factual + mandatory CTA)
|
|
|
|
|
|
|
| 76 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
REEL_TYPES = [
|
| 78 |
"Top 5", "Fact", "Ranking", "Step by Step Guide",
|
|
|
|
| 82 |
|
| 83 |
REEL_PROMPTS = {
|
| 84 |
"Top 5": (
|
| 85 |
+
"Create a viral 'Top 5' reel script based STRICTLY on the provided content.\n"
|
| 86 |
+
"Do NOT invent facts. Only use information explicitly stated.\n"
|
| 87 |
+
"Format: hook sentence, then exactly 5 items numbered 5 down to 1.\n"
|
| 88 |
+
"Each line: 15-20 words. The LAST line must be a call-to-action with the source domain (e.g., 'Visit ChainStreet.io for the full list').\n"
|
| 89 |
+
"Output ONLY {n} lines total, one per line. No extra text."
|
| 90 |
),
|
| 91 |
"Fact": (
|
| 92 |
+
"Create a FACTUAL reel script based STRICTLY on the provided news article or content.\n"
|
| 93 |
+
"Do NOT invent statistics, quotes, or events. Only use information explicitly stated.\n"
|
| 94 |
+
"Open with the most striking fact from the content as a hook.\n"
|
| 95 |
+
"Each of the {n} lines must be a true, verifiable fact from the content (15-20 words per line).\n"
|
| 96 |
+
"The LAST line MUST be a clear call-to-action that includes the source domain (e.g., 'Visit ChainStreet.io for the full story' or 'Link in bio for more details').\n"
|
| 97 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 98 |
),
|
| 99 |
"Ranking": (
|
| 100 |
+
"Create a RANKING reel script based STRICTLY on the provided content.\n"
|
| 101 |
+
"Do not add opinions or rankings not present in the content.\n"
|
| 102 |
+
"Start with a strong hook. Each item has a clear rank from best to worst.\n"
|
| 103 |
+
"Every line: 15-20 words. The LAST line must be a call-to-action with the source domain.\n"
|
| 104 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 105 |
),
|
| 106 |
"Step by Step Guide": (
|
| 107 |
+
"Create a STEP-BY-STEP GUIDE reel script using ONLY the information from the provided content.\n"
|
| 108 |
+
"Hook first, then clear numbered steps (Step 1: β¦, Step 2: β¦).\n"
|
| 109 |
+
"Every line: 15-20 words. The LAST line must be a call-to-action with the source domain.\n"
|
| 110 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 111 |
),
|
| 112 |
"Statistics": (
|
| 113 |
+
"Create a STATISTICS reel script with data points from the provided content only.\n"
|
| 114 |
+
"Do not invent numbers. Lead with the most striking statistic.\n"
|
| 115 |
+
"Every line: 15-20 words, include a specific number or percentage from the content.\n"
|
| 116 |
+
"The LAST line must be a call-to-action with the source domain.\n"
|
| 117 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 118 |
),
|
| 119 |
"Quiz": (
|
| 120 |
+
"Create an interactive QUIZ reel script based on the provided content.\n"
|
| 121 |
+
"Open with 'Can you answer these?' then pose {n} quiz questions based on facts from the content.\n"
|
| 122 |
+
"Close the last line with a CTA that includes the source domain (e.g., 'Check your answers at ChainStreet.io').\n"
|
| 123 |
"Every line: 15-20 words. Output ONLY {n} lines, one per line. No extra text."
|
| 124 |
),
|
| 125 |
"Famous Quotes": (
|
| 126 |
+
"Create a FAMOUS QUOTES reel script related to the provided content.\n"
|
| 127 |
+
"Each line is an accurate quote followed by β Author Name. Only use quotes mentioned in the content.\n"
|
| 128 |
+
"The LAST line must include a call-to-action with the source domain.\n"
|
| 129 |
"Output ONLY {n} quote lines, one per line. No extra text."
|
| 130 |
),
|
| 131 |
"Product Demo": (
|
| 132 |
+
"Create a PRODUCT/IDEA DEMO reel script using ONLY details from the provided content.\n"
|
| 133 |
"Hook with the core problem, then explain the solution step by step.\n"
|
| 134 |
+
"Close with a call-to-action that includes the source domain. Every line: 15-20 words.\n"
|
| 135 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 136 |
),
|
| 137 |
"Joke": (
|
| 138 |
+
"Create a COMEDY reel script based on the provided content (if humorous) or general topic.\n"
|
| 139 |
+
"Build-up plus punchline format. Each line: 15-18 words.\n"
|
| 140 |
+
"The LAST line must be a call-to-action that includes the source domain.\n"
|
| 141 |
"Output ONLY {n} lines, one per line. No extra text."
|
| 142 |
),
|
| 143 |
"Blog to Reel": (
|
| 144 |
+
"Distill the provided news article or blog post into a factual, accurate reel script.\n"
|
| 145 |
+
"Only use information from the article. Do not add opinions or fake data.\n"
|
| 146 |
+
"Hook first, then key takeaways, and end with a CTA that includes the source domain.\n"
|
| 147 |
+
"Each line: 15-20 words. Output ONLY {n} lines, one per line."
|
| 148 |
),
|
| 149 |
"Custom Prompt": (
|
| 150 |
+
"You are a senior financial journalist. Write a FACTUAL, accurate short-form script based ONLY on the provided news content.\n"
|
| 151 |
+
"Do NOT add speculation, invented numbers, or quotes not present in the content.\n"
|
| 152 |
+
"Structure: strong hook β key conflict β stakes β rhetorical question β clear CTA.\n"
|
| 153 |
+
"Each line: 15-20 words, authoritative and precise.\n"
|
| 154 |
+
"The LAST line MUST be a call-to-action that includes the source domain (e.g., 'Full analysis at ChainStreet.io β link in bio').\n"
|
| 155 |
+
"Output exactly {n} lines, one per line. No extra text."
|
| 156 |
),
|
| 157 |
}
|
| 158 |
|
|
|
|
| 197 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
def scrape_url(url: str) -> str:
|
| 199 |
try:
|
| 200 |
+
import trafilatura
|
| 201 |
dl = trafilatura.fetch_url(url)
|
| 202 |
text = trafilatura.extract(dl, include_tables=False,
|
| 203 |
include_comments=False, favor_recall=True)
|
|
|
|
| 207 |
return ""
|
| 208 |
|
| 209 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 210 |
+
# SCRIPT GENERATION (groq lazy-loaded)
|
| 211 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
def generate_script(
|
| 213 |
content: str,
|
|
|
|
| 215 |
reel_type: str = "Fact",
|
| 216 |
num_points: int = 8,
|
| 217 |
) -> list[str]:
|
| 218 |
+
from groq import Groq
|
| 219 |
client = Groq(api_key=groq_key.strip())
|
| 220 |
template = REEL_PROMPTS.get(reel_type, REEL_PROMPTS["Custom Prompt"])
|
| 221 |
system = template.format(n=num_points)
|
|
|
|
| 230 |
max_tokens=700,
|
| 231 |
)
|
| 232 |
raw = resp.choices[0].message.content.strip()
|
|
|
|
| 233 |
lines = [
|
| 234 |
re.sub(r"^[\d]+[.)]\s*|^[-β’*]\s*|^Step\s+\d+:\s*", "", l).strip()
|
| 235 |
for l in raw.splitlines() if l.strip()
|
| 236 |
]
|
| 237 |
+
lines = [l for l in lines if l]
|
| 238 |
return lines[:num_points]
|
| 239 |
|
| 240 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 241 |
+
# AUDIO β Edge TTS (robust, with fallback)
|
| 242 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 243 |
async def _edge_tts_save(text: str, voice: str, path: str) -> None:
|
| 244 |
+
import edge_tts
|
| 245 |
await edge_tts.Communicate(text, voice).save(path)
|
| 246 |
|
| 247 |
def generate_audio_edge(text: str, voice_key: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
voice = EDGE_VOICES.get(voice_key)
|
| 249 |
if voice is None:
|
| 250 |
print(f"[TTS] β Unknown voice key {voice_key!r} β using default", flush=True)
|
|
|
|
| 252 |
print(f"[TTS] Using voice: {voice} (key={voice_key!r})", flush=True)
|
| 253 |
|
| 254 |
out = tempfile.mktemp(suffix=".mp3")
|
|
|
|
| 255 |
loop = asyncio.new_event_loop()
|
| 256 |
try:
|
| 257 |
loop.run_until_complete(_edge_tts_save(text, voice, out))
|
|
|
|
| 260 |
return out
|
| 261 |
|
| 262 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 263 |
+
# AUDIO β Kokoro TTS (optional)
|
| 264 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 265 |
_kokoro_pipeline = None
|
| 266 |
_kokoro_available = None
|
|
|
|
| 269 |
global _kokoro_available
|
| 270 |
if _kokoro_available is None:
|
| 271 |
try:
|
| 272 |
+
import kokoro
|
| 273 |
_kokoro_available = True
|
| 274 |
except ImportError:
|
| 275 |
_kokoro_available = False
|
| 276 |
return _kokoro_available
|
| 277 |
|
| 278 |
def generate_audio_kokoro(text: str, voice_key: str) -> str:
|
| 279 |
+
import soundfile as sf
|
| 280 |
if not _check_kokoro():
|
| 281 |
+
raise ImportError("Kokoro TTS not installed")
|
| 282 |
global _kokoro_pipeline
|
| 283 |
if _kokoro_pipeline is None:
|
| 284 |
from kokoro import KPipeline
|
|
|
|
| 290 |
return out
|
| 291 |
|
| 292 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
# PEXELS β fetch 2β3 diverse background videos
|
| 294 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 295 |
_STOP = frozenset({
|
| 296 |
"the","a","an","is","are","was","were","be","been","have","has","had","do","does","did",
|
|
|
|
| 303 |
})
|
| 304 |
|
| 305 |
def _extract_queries(sentences: list[str], count: int = 3) -> list[str]:
|
|
|
|
| 306 |
text = " ".join(sentences)
|
| 307 |
words = re.sub(r"[^a-zA-Z\s]", "", text).lower().split()
|
| 308 |
kws = [w for w in words if w not in _STOP and len(w) > 3]
|
|
|
|
| 309 |
seen, uniq = set(), []
|
| 310 |
for w in kws:
|
| 311 |
if w not in seen:
|
| 312 |
seen.add(w)
|
| 313 |
uniq.append(w)
|
| 314 |
+
n = max(1, len(uniq) // count)
|
|
|
|
| 315 |
queries = []
|
| 316 |
for i in range(count):
|
| 317 |
bucket = uniq[i * n : i * n + 3]
|
| 318 |
if bucket:
|
| 319 |
queries.append(" ".join(bucket))
|
|
|
|
| 320 |
fallbacks = ["nature landscape", "city streets night", "technology abstract"]
|
| 321 |
while len(queries) < count:
|
| 322 |
queries.append(fallbacks[len(queries) % len(fallbacks)])
|
| 323 |
return queries[:count]
|
| 324 |
|
| 325 |
def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
|
|
|
| 326 |
try:
|
| 327 |
resp = requests.get(
|
| 328 |
"https://api.pexels.com/videos/search",
|
|
|
|
| 336 |
if vid_id in used_ids:
|
| 337 |
continue
|
| 338 |
files = vid.get("video_files", [])
|
| 339 |
+
portrait = [f for f in files if f.get("width", 9999) < f.get("height", 0) and f.get("width", 0) >= 360]
|
|
|
|
|
|
|
|
|
|
| 340 |
candidates = sorted(portrait or files, key=lambda x: x.get("width", 0), reverse=True)
|
| 341 |
if not candidates:
|
| 342 |
continue
|
|
|
|
| 355 |
return None
|
| 356 |
|
| 357 |
def fetch_bg_videos(sentences: list[str], api_key: str, count: int = 3) -> list[str]:
|
|
|
|
| 358 |
queries = _extract_queries(sentences, count=count)
|
| 359 |
used_ids = set()
|
| 360 |
paths = []
|
|
|
|
| 409 |
draw = ImageDraw.Draw(img)
|
| 410 |
font = get_font(60, bold=True)
|
| 411 |
|
|
|
|
| 412 |
words, lines, cur = text.split(), [], []
|
| 413 |
for word in words:
|
| 414 |
test = " ".join(cur + [word])
|
|
|
|
| 427 |
box_y1 = max(pad_h, (height - total_text_h) // 2 - pad_v)
|
| 428 |
box_y2 = min(height - pad_h, box_y1 + total_text_h + pad_v * 2)
|
| 429 |
|
|
|
|
| 430 |
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
| 431 |
od = ImageDraw.Draw(overlay)
|
| 432 |
od.rounded_rectangle([20, box_y1, width - 20, box_y2], radius=22, fill=(6, 6, 20, 190))
|
|
|
|
| 434 |
img = Image.alpha_composite(img, overlay)
|
| 435 |
draw = ImageDraw.Draw(img)
|
| 436 |
|
|
|
|
| 437 |
y = box_y1 + pad_v
|
| 438 |
for line in lines:
|
| 439 |
bbox = draw.textbbox((0, 0), line, font=font)
|
|
|
|
| 449 |
return np.array(img)
|
| 450 |
|
| 451 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 452 |
+
# VIDEO ASSEMBLY (main reel with stitched background videos)
|
| 453 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 454 |
def _fit_bg(clip, W: int, H: int):
|
|
|
|
| 455 |
if clip.w / clip.h > W / H:
|
| 456 |
clip = clip.resize(height=H)
|
| 457 |
else:
|
|
|
|
| 463 |
return clip
|
| 464 |
|
| 465 |
def _gradient_clip(W: int, H: int, accent_color: tuple, duration: float):
|
|
|
|
| 466 |
import moviepy.editor as mpe
|
| 467 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
| 468 |
r0, g0, b0 = (max(0, accent_color[0] - 60),
|
| 469 |
max(0, accent_color[1] - 60),
|
| 470 |
max(0, accent_color[2] - 80))
|
| 471 |
for i in range(H):
|
| 472 |
+
t = i / H
|
| 473 |
frame[i] = [int(r0 + t * 60), int(g0 + t * 20), int(b0 + t * 80)]
|
| 474 |
return mpe.ImageClip(frame).set_duration(duration)
|
| 475 |
|
| 476 |
def create_reel(
|
| 477 |
sentences: list[str],
|
| 478 |
audio_path: str,
|
| 479 |
+
bg_video_paths: list[str],
|
| 480 |
logo_path: str | None = None,
|
| 481 |
logo_pos: str = "top-right",
|
| 482 |
accent_hex: str = "#7c3aed",
|
| 483 |
) -> str:
|
| 484 |
+
import moviepy.editor as mpe
|
|
|
|
| 485 |
W, H = VIDEO_W, VIDEO_H
|
| 486 |
|
| 487 |
try:
|
| 488 |
+
h = accent_hex.lstrip("#")
|
| 489 |
+
accent_color = tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 490 |
except Exception:
|
| 491 |
accent_color = (124, 58, 237)
|
| 492 |
|
| 493 |
+
logo = load_logo(logo_path)
|
| 494 |
+
audio = mpe.AudioFileClip(audio_path)
|
| 495 |
total_dur = audio.duration
|
| 496 |
+
n_sents = len(sentences)
|
| 497 |
+
dur_each = total_dur / n_sents
|
| 498 |
|
| 499 |
+
print(f"[reel] duration={total_dur:.1f}s | {n_sents} sentences | {len(bg_video_paths)} bg clips", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
+
# Stitch background videos
|
| 502 |
+
num_bg = max(1, len(bg_video_paths))
|
| 503 |
seg_dur = total_dur / num_bg
|
| 504 |
bg_segs = []
|
| 505 |
|
|
|
|
| 509 |
try:
|
| 510 |
bg = mpe.VideoFileClip(bg_video_paths[i], audio=False)
|
| 511 |
bg = _fit_bg(bg, W, H)
|
|
|
|
| 512 |
if bg.duration < seg_dur:
|
| 513 |
loops = int(np.ceil(seg_dur / bg.duration)) + 1
|
| 514 |
+
bg = mpe.concatenate_videoclips([bg] * loops)
|
| 515 |
bg = bg.subclip(0, seg_dur)
|
|
|
|
| 516 |
dark = mpe.ColorClip((W, H), color=[0, 0, 0]).set_opacity(0.42).set_duration(seg_dur)
|
| 517 |
+
seg = mpe.CompositeVideoClip([bg, dark]).set_start(start)
|
| 518 |
bg_segs.append(seg)
|
| 519 |
continue
|
| 520 |
except Exception as e:
|
| 521 |
print(f"[reel] bg clip {i} failed ({e}), using gradient", flush=True)
|
|
|
|
| 522 |
grad = _gradient_clip(W, H, accent_color, seg_dur).set_start(start)
|
| 523 |
bg_segs.append(grad)
|
| 524 |
|
| 525 |
bg_layer = mpe.CompositeVideoClip(bg_segs, size=(W, H)).set_duration(total_dur)
|
| 526 |
|
| 527 |
+
# Text overlays
|
| 528 |
text_clips = []
|
| 529 |
for i, sentence in enumerate(sentences):
|
| 530 |
arr = render_text_frame(sentence, W, H, logo, logo_pos, accent_color)
|
| 531 |
+
tc = (mpe.ImageClip(arr)
|
| 532 |
+
.set_start(i * dur_each)
|
| 533 |
+
.set_duration(dur_each)
|
| 534 |
+
.crossfadein(0.30)
|
| 535 |
+
.crossfadeout(0.15))
|
|
|
|
|
|
|
| 536 |
text_clips.append(tc)
|
| 537 |
|
|
|
|
| 538 |
final = mpe.CompositeVideoClip([bg_layer] + text_clips).set_audio(audio)
|
| 539 |
+
out = tempfile.mktemp(suffix=".mp4")
|
| 540 |
+
final.write_videofile(out, codec="libx264", audio_codec="aac",
|
| 541 |
+
fps=FPS, preset="ultrafast", threads=4, logger=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
print(f"[reel] β Written to {out}", flush=True)
|
| 543 |
return out
|
| 544 |
|
| 545 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 546 |
+
# NEWS ANCHOR MODE (simplified, kept from previous working version)
|
| 547 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 548 |
def _make_studio_bg(W: int, H: int, dark: bool, accent_color: tuple) -> np.ndarray:
|
| 549 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
|
|
|
| 556 |
ow, oh = 400, 400
|
| 557 |
ox, oy = W // 2 - ow // 2, H // 2 - oh // 2
|
| 558 |
draw.ellipse([ox, oy, ox + ow, oy + oh], outline=(*accent_color, 60), width=3)
|
| 559 |
+
draw.ellipse([ox + 50, oy + 50, ox + ow - 50, oy + oh - 50], outline=(*accent_color, 28), width=2)
|
|
|
|
| 560 |
draw.rectangle([0, H - 290, W, H - 240], fill=accent_color)
|
| 561 |
return np.array(img)
|
| 562 |
|
|
|
|
| 565 |
bar_y = H - 235
|
| 566 |
draw.rectangle([0, bar_y, W, bar_y + bar_h], fill=(*accent_color, 220))
|
| 567 |
if name:
|
| 568 |
+
draw.text((32, bar_y + 8), name.upper(), font=font_bold, fill=(255, 255, 255, 255))
|
| 569 |
if title:
|
| 570 |
+
draw.text((32, bar_y + 50), title, font=font_light, fill=(220, 220, 220, 220))
|
| 571 |
|
| 572 |
def _draw_ticker(draw, font, ticker_text, W, H, frame_num, scroll_speed=4):
|
| 573 |
ticker_h = 50
|
| 574 |
+
bar_y = H - ticker_h
|
| 575 |
draw.rectangle([0, bar_y, W, H], fill=(18, 18, 18, 235))
|
| 576 |
offset = W - (frame_num * scroll_speed % (W + len(ticker_text) * 14))
|
| 577 |
+
draw.text((offset, bar_y + 8), f" β {ticker_text} β {ticker_text} β {ticker_text}",
|
| 578 |
+
font=font, fill=(255, 215, 0, 255))
|
|
|
|
|
|
|
|
|
|
| 579 |
|
| 580 |
def process_anchor_video(
|
| 581 |
anchor_video_path: str,
|
|
|
|
| 590 |
progress,
|
| 591 |
) -> str:
|
| 592 |
import moviepy.editor as mpe
|
|
|
|
| 593 |
try:
|
| 594 |
+
h = accent_hex.lstrip("#")
|
| 595 |
+
accent_color = tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 596 |
except Exception:
|
| 597 |
accent_color = (5, 38, 120)
|
| 598 |
|
| 599 |
progress(0.10, desc="πΉ Loading anchor videoβ¦")
|
| 600 |
clip = mpe.VideoFileClip(anchor_video_path)
|
| 601 |
W, H = VIDEO_W, VIDEO_H
|
| 602 |
+
dur = clip.duration
|
| 603 |
|
| 604 |
+
font_bold = get_font(36, bold=True)
|
| 605 |
+
font_light = get_font(28, bold=False)
|
| 606 |
ticker_font = get_font(24, bold=False)
|
| 607 |
+
logo = load_logo(logo_path)
|
| 608 |
|
| 609 |
progress(0.22, desc="π¨ Preparing backgroundβ¦")
|
| 610 |
if bg_choice == "Blur original":
|
| 611 |
+
bg_clip = clip.fl_image(lambda f: np.array(Image.fromarray(f).filter(ImageFilter.GaussianBlur(radius=18))))
|
|
|
|
|
|
|
| 612 |
bg_clip = _fit_bg(bg_clip, W, H)
|
| 613 |
elif bg_choice == "News studio (dark)":
|
| 614 |
sf = _make_studio_bg(W, H, dark=True, accent_color=accent_color)
|
|
|
|
| 618 |
bg_clip = mpe.ImageClip(sf).set_duration(dur)
|
| 619 |
elif bg_choice == "Pexels" and pexels_key.strip():
|
| 620 |
kw = news_topic or "news studio broadcast"
|
| 621 |
+
p = _fetch_one_video(kw, pexels_key, set())
|
| 622 |
if p:
|
| 623 |
pex = mpe.VideoFileClip(p, audio=False)
|
| 624 |
pex = _fit_bg(pex, W, H)
|
| 625 |
if pex.duration < dur:
|
| 626 |
loops = int(np.ceil(dur / pex.duration)) + 1
|
| 627 |
+
pex = mpe.concatenate_videoclips([pex] * loops)
|
| 628 |
bg_clip = pex.subclip(0, dur)
|
| 629 |
else:
|
| 630 |
sf = _make_studio_bg(W, H, dark=True, accent_color=accent_color)
|
| 631 |
bg_clip = mpe.ImageClip(sf).set_duration(dur)
|
| 632 |
else:
|
| 633 |
+
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
| 634 |
frame[:, :] = accent_color
|
| 635 |
+
bg_clip = mpe.ImageClip(frame).set_duration(dur)
|
| 636 |
|
| 637 |
progress(0.45, desc="βοΈ Compositing anchorβ¦")
|
| 638 |
anchor_w = int(W * 0.72)
|
|
|
|
| 643 |
anchor_clip = clip.resize(width=anchor_w).set_position(((W - anchor_w) // 2, int(H * 0.08)))
|
| 644 |
|
| 645 |
progress(0.60, desc="πΌοΈ Adding news graphicsβ¦")
|
|
|
|
| 646 |
def add_overlay(get_frame, t):
|
| 647 |
frame = get_frame(t)
|
| 648 |
+
img = Image.fromarray(frame).convert("RGBA")
|
| 649 |
if logo:
|
| 650 |
img = paste_logo(img, logo, "top-left")
|
| 651 |
draw = ImageDraw.Draw(img)
|
|
|
|
| 658 |
return np.array(img.convert("RGB"))
|
| 659 |
|
| 660 |
composite = mpe.CompositeVideoClip([bg_clip.set_duration(dur), anchor_clip], size=(W, H))
|
| 661 |
+
final = composite.fl(add_overlay, apply_to=["video"])
|
| 662 |
if clip.audio:
|
| 663 |
final = final.set_audio(clip.audio)
|
| 664 |
|
| 665 |
progress(0.80, desc="ποΈ Rendering final videoβ¦")
|
| 666 |
out = tempfile.mktemp(suffix=".mp4")
|
| 667 |
+
final.write_videofile(out, codec="libx264", audio_codec="aac",
|
| 668 |
+
fps=min(FPS, int(clip.fps or FPS)),
|
| 669 |
+
preset="ultrafast", threads=4, logger=None)
|
|
|
|
|
|
|
| 670 |
return out
|
| 671 |
|
| 672 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 673 |
+
# MAIN PIPELINES: Step 1 β Generate Script only
|
| 674 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 675 |
+
def generate_script_only(
|
| 676 |
+
url_or_text, groq_key, reel_type, num_points, progress=gr.Progress(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
):
|
| 678 |
try:
|
| 679 |
if not groq_key.strip():
|
| 680 |
+
return "", "β **Groq API key required.** Free at [console.groq.com](https://console.groq.com)"
|
| 681 |
|
| 682 |
progress(0.05, desc="π Fetching contentβ¦")
|
| 683 |
raw = url_or_text.strip()
|
| 684 |
if raw.startswith("http"):
|
| 685 |
content = scrape_url(raw)
|
| 686 |
if not content or len(content) < 60:
|
| 687 |
+
return "", "β Could not extract text from that URL. Try pasting the article text directly."
|
|
|
|
|
|
|
|
|
|
| 688 |
else:
|
| 689 |
content = raw
|
| 690 |
+
if len(content) < 20:
|
| 691 |
+
return "", "β Please enter a URL or a text topic."
|
| 692 |
+
|
| 693 |
+
progress(0.2, desc=f"βοΈ Writing '{reel_type}' scriptβ¦")
|
| 694 |
+
sentences = generate_script(content, groq_key, reel_type, int(num_points))
|
| 695 |
+
if not sentences:
|
| 696 |
+
return "", "β Script generation failed. Check your Groq API key."
|
| 697 |
+
|
| 698 |
+
script_text = "\n".join(sentences)
|
| 699 |
+
script_md = "\n\n".join(f"**{i+1}.** {s}" for i, s in enumerate(sentences))
|
| 700 |
+
|
| 701 |
+
progress(1.0, desc="β
Script generated!")
|
| 702 |
+
return script_text, f"## β
Script Ready!\n\n**Type:** {reel_type}\n\n**Edit below, then click 'Create Video'.**\n\n{script_md}"
|
| 703 |
+
|
| 704 |
+
except Exception as e:
|
| 705 |
+
import traceback
|
| 706 |
+
return "", f"β **Error:** {str(e)}\n\n```\n{traceback.format_exc()}\n```"
|
| 707 |
+
|
| 708 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 709 |
+
# MAIN PIPELINES: Step 2 β Create Video from edited script + optional outro
|
| 710 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 711 |
+
def create_video_from_script(
|
| 712 |
+
script_text, groq_key, pexels_key,
|
| 713 |
+
tts_engine, edge_voice, kokoro_voice,
|
| 714 |
+
logo_file, logo_pos, accent_hex, show_caption_bg,
|
| 715 |
+
outro_video, # path to outro video (or None)
|
| 716 |
+
progress=gr.Progress(),
|
| 717 |
+
):
|
| 718 |
+
try:
|
| 719 |
+
if not groq_key.strip():
|
| 720 |
+
return None, "β **Groq API key required.** Please provide it or set GROQ_API_KEY secret."
|
| 721 |
|
| 722 |
+
if not pexels_key.strip():
|
| 723 |
+
pexels_key = os.getenv("PEXELS_API_KEY", "")
|
| 724 |
+
|
| 725 |
+
sentences = [line.strip() for line in script_text.strip().split("\n") if line.strip()]
|
| 726 |
if not sentences:
|
| 727 |
+
return None, "β Script is empty. Please generate a script first."
|
| 728 |
|
| 729 |
full_script = " ".join(sentences)
|
| 730 |
+
if not full_script.strip():
|
| 731 |
+
return None, "β Generated script is empty. Please check your content or try again."
|
| 732 |
+
print(f"[create_video] Full script length: {len(full_script)} chars")
|
|
|
|
|
|
|
|
|
|
| 733 |
|
|
|
|
| 734 |
using_kokoro = tts_engine == "Kokoro TTS" and _check_kokoro()
|
| 735 |
+
progress(0.40, desc=f"ποΈ Generating voice β {'Kokoro' if using_kokoro else 'Edge TTS'}β¦")
|
|
|
|
|
|
|
| 736 |
if using_kokoro:
|
| 737 |
try:
|
| 738 |
audio_path = generate_audio_kokoro(full_script, kokoro_voice)
|
| 739 |
except Exception as e:
|
| 740 |
+
print(f"[kokoro fallback] {e}")
|
| 741 |
audio_path = generate_audio_edge(full_script, edge_voice)
|
|
|
|
| 742 |
else:
|
| 743 |
audio_path = generate_audio_edge(full_script, edge_voice)
|
| 744 |
|
|
|
|
| 745 |
bg_videos = []
|
| 746 |
if pexels_key.strip():
|
| 747 |
+
progress(0.60, desc="π¬ Fetching background videosβ¦")
|
| 748 |
bg_videos = fetch_bg_videos(sentences, pexels_key, count=3)
|
| 749 |
|
| 750 |
+
progress(0.75, desc="ποΈ Assembling main reelβ¦")
|
| 751 |
+
logo_path = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
|
| 752 |
+
main_reel_path = create_reel(sentences, audio_path, bg_videos, logo_path, logo_pos, accent_hex)
|
| 753 |
+
|
| 754 |
+
# ββ Add outro video if provided ββββββββββββββββββββββββββββββββββββββ
|
| 755 |
+
if outro_video:
|
| 756 |
+
progress(0.85, desc="β Adding outro videoβ¦")
|
| 757 |
+
import moviepy.editor as mpe
|
| 758 |
+
outro_path = outro_video if isinstance(outro_video, str) else outro_video.name
|
| 759 |
+
try:
|
| 760 |
+
if not os.path.isfile(outro_path):
|
| 761 |
+
print(f"[outro] File not found: {outro_path}")
|
| 762 |
+
raise FileNotFoundError(f"Outro file not found: {outro_path}")
|
| 763 |
+
outro_clip = mpe.VideoFileClip(outro_path)
|
| 764 |
+
|
| 765 |
+
# Resize outro to match main reel dimensions
|
| 766 |
+
W, H = VIDEO_W, VIDEO_H
|
| 767 |
+
if outro_clip.w / outro_clip.h > W / H:
|
| 768 |
+
outro_clip = outro_clip.resize(height=H)
|
| 769 |
+
else:
|
| 770 |
+
outro_clip = outro_clip.resize(width=W)
|
| 771 |
+
if outro_clip.w > W:
|
| 772 |
+
outro_clip = outro_clip.crop(x_center=outro_clip.w / 2, width=W)
|
| 773 |
+
if outro_clip.h > H:
|
| 774 |
+
outro_clip = outro_clip.crop(y_center=outro_clip.h / 2, height=H)
|
| 775 |
+
|
| 776 |
+
main_clip = mpe.VideoFileClip(main_reel_path)
|
| 777 |
+
final_clip = mpe.concatenate_videoclips([main_clip, outro_clip], method="compose")
|
| 778 |
+
final_path = tempfile.mktemp(suffix=".mp4")
|
| 779 |
+
final_clip.write_videofile(final_path, codec="libx264", audio_codec="aac",
|
| 780 |
+
fps=FPS, preset="ultrafast", threads=4, logger=None)
|
| 781 |
+
os.unlink(main_reel_path)
|
| 782 |
+
main_reel_path = final_path
|
| 783 |
+
progress(0.95, desc="β
Outro added")
|
| 784 |
+
except Exception as e:
|
| 785 |
+
print(f"[outro] Error: {e}, continuing without outro")
|
| 786 |
+
|
| 787 |
+
script_md = "\n\n".join(f"**{i+1}.** {s}" for i, s in enumerate(sentences))
|
| 788 |
+
progress(1.0, desc="β
Video ready!")
|
| 789 |
+
return main_reel_path, f"## β
Reel Ready!\n\n**Script:**\n\n{script_md}"
|
| 790 |
|
| 791 |
except Exception as e:
|
| 792 |
import traceback
|
| 793 |
+
return None, f"β **Error:** {str(e)}\n\n```\n{traceback.format_exc()}\n```"
|
| 794 |
|
| 795 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 796 |
+
# ANCHOR PIPELINE (unchanged)
|
| 797 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 798 |
def anchor_pipeline(
|
| 799 |
+
anchor_video, bg_choice, pexels_key,
|
| 800 |
+
news_topic, anchor_name, anchor_title, ticker_text,
|
| 801 |
+
logo_file, accent_hex,
|
| 802 |
+
progress=gr.Progress(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
):
|
| 804 |
try:
|
| 805 |
if anchor_video is None:
|
| 806 |
return None, "β Please upload your anchor video first."
|
| 807 |
video_path = anchor_video if isinstance(anchor_video, str) else anchor_video.name
|
| 808 |
+
logo_path = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
|
| 809 |
out = process_anchor_video(
|
| 810 |
video_path, bg_choice, pexels_key,
|
| 811 |
news_topic, anchor_name, anchor_title, ticker_text,
|
|
|
|
| 814 |
return out, "## β
News Anchor Reel Ready!"
|
| 815 |
except Exception as e:
|
| 816 |
import traceback
|
| 817 |
+
return None, f"β **Error:** {str(e)}\n\n```\n{traceback.format_exc()}\n```"
|
| 818 |
|
| 819 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 820 |
# GRADIO UI
|
|
|
|
| 827 |
border: none !important; color: #fff !important; }
|
| 828 |
"""
|
| 829 |
|
| 830 |
+
with gr.Blocks(title="π¬ AI Reels Maker", css=CSS, theme=gr.themes.Soft()) as demo:
|
| 831 |
|
| 832 |
gr.Markdown(
|
| 833 |
"# π¬ AI Reels Maker\n"
|
|
|
|
| 835 |
)
|
| 836 |
|
| 837 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 838 |
+
# TAB 1 β Reel Generator (editable script + outro)
|
| 839 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 840 |
with gr.Tab("π¬ Reel Generator"):
|
| 841 |
with gr.Row():
|
| 842 |
|
|
|
|
| 843 |
with gr.Column(scale=1):
|
| 844 |
url_input = gr.Textbox(
|
| 845 |
label="π URL or Topic",
|
| 846 |
+
placeholder="Paste a news article URL, blog post URL, or type a topicβ¦",
|
| 847 |
lines=3,
|
| 848 |
)
|
| 849 |
reel_type = gr.Dropdown(
|
|
|
|
| 854 |
|
| 855 |
with gr.Accordion("βοΈ API Keys", open=True):
|
| 856 |
with gr.Row():
|
| 857 |
+
groq_key = gr.Textbox(label="π Groq API Key", type="password", placeholder="gsk_β¦ (free at console.groq.com)")
|
| 858 |
+
pexels_key = gr.Textbox(label="π₯ Pexels API Key", type="password", placeholder="Optional β fetches background videos")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
|
| 860 |
with gr.Accordion("ποΈ Voice", open=True):
|
| 861 |
tts_engine = gr.Radio(
|
| 862 |
choices=["Edge TTS", "Kokoro TTS"],
|
| 863 |
value="Edge TTS",
|
| 864 |
label="Voice Engine",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
)
|
| 866 |
with gr.Row():
|
| 867 |
+
edge_voice = gr.Dropdown(choices=list(EDGE_VOICES.keys()), value=DEFAULT_VOICE_KEY, label="Edge TTS Voice", visible=True)
|
| 868 |
+
kokoro_voice = gr.Dropdown(choices=list(KOKORO_VOICES.keys()), value="Heart (US Female)", label="Kokoro Voice", visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
|
| 870 |
with gr.Accordion("π¨ Branding", open=False):
|
| 871 |
+
logo_file = gr.File(label="π€ Upload Logo (PNG/JPG)", file_types=["image"], type="filepath")
|
| 872 |
+
logo_pos = gr.Dropdown(choices=["top-right","top-left","bottom-right","bottom-left"], value="top-right", label="Logo Position")
|
| 873 |
+
accent_hex = gr.ColorPicker(value="#d4af37", label="Accent Color")
|
| 874 |
+
show_caption_bg = gr.Checkbox(label="Show caption background", value=True, info="Toggle semi-transparent card behind text")
|
| 875 |
+
outro_video = gr.File(label="π¬ Optional Outro Video (plays after the reel)", file_types=["video"], type="filepath")
|
|
|
|
|
|
|
| 876 |
|
| 877 |
+
num_points = gr.Slider(6, 10, value=8, step=1, label="π Number of points (8 = ~55s reel)")
|
| 878 |
+
gen_btn = gr.Button("π Generate Script", variant="primary", size="lg", elem_classes=["gen-btn"])
|
| 879 |
+
create_btn = gr.Button("π¬ Create Video", variant="secondary", size="lg")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
|
|
|
|
| 881 |
with gr.Column(scale=1):
|
| 882 |
+
script_editor = gr.TextArea(label="βοΈ Edit Script (one sentence per line)", lines=8, interactive=True)
|
| 883 |
video_out = gr.Video(label="π¬ Your Reel", height=560)
|
| 884 |
script_out = gr.Markdown()
|
| 885 |
|
|
|
|
| 886 |
tts_engine.change(
|
| 887 |
+
lambda e: (gr.update(visible=e=="Edge TTS"), gr.update(visible=e=="Kokoro TTS")),
|
| 888 |
+
inputs=tts_engine, outputs=[edge_voice, kokoro_voice],
|
|
|
|
| 889 |
)
|
| 890 |
gen_btn.click(
|
| 891 |
+
generate_script_only,
|
| 892 |
+
inputs=[url_input, groq_key, reel_type, num_points],
|
| 893 |
+
outputs=[script_editor, script_out],
|
| 894 |
+
)
|
| 895 |
+
create_btn.click(
|
| 896 |
+
create_video_from_script,
|
| 897 |
+
inputs=[script_editor, groq_key, pexels_key, tts_engine, edge_voice, kokoro_voice,
|
| 898 |
+
logo_file, logo_pos, accent_hex, show_caption_bg, outro_video],
|
| 899 |
outputs=[video_out, script_out],
|
| 900 |
)
|
| 901 |
|
|
|
|
| 904 |
# βββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββ
|
| 905 |
with gr.Tab("πΊ News Anchor Mode"):
|
| 906 |
gr.Markdown(
|
| 907 |
+
"**Upload your anchor video** and the app will:\n"
|
| 908 |
+
"- Replace or style the background (blur / news studio / Pexels video / solid)\n"
|
| 909 |
+
"- Add a professional **lower-third** with your name & title\n"
|
| 910 |
+
"- Add a **scrolling news ticker** at the bottom\n"
|
| 911 |
+
"- Overlay your **channel logo**"
|
| 912 |
)
|
| 913 |
with gr.Row():
|
| 914 |
with gr.Column(scale=1):
|
| 915 |
anchor_video = gr.Video(label="πΉ Upload Anchor Video (MP4/MOV)")
|
| 916 |
bg_choice = gr.Dropdown(
|
| 917 |
+
choices=["Blur original","News studio (dark)","News studio (light)","Pexels","Solid color"],
|
|
|
|
|
|
|
|
|
|
| 918 |
value="News studio (dark)",
|
| 919 |
label="π¨ Background Style",
|
| 920 |
)
|
| 921 |
+
a_pexels_key = gr.Textbox(label="π₯ Pexels API Key", type="password",
|
| 922 |
+
placeholder="Required if Background = Pexels")
|
| 923 |
+
news_topic = gr.Textbox(label="π Pexels search keyword", placeholder="e.g. 'city news'", visible=False)
|
| 924 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
with gr.Accordion("πͺͺ Name & Title (Lower Third)", open=True):
|
| 926 |
anchor_name = gr.Textbox(label="Anchor Name", placeholder="Jane Doe")
|
| 927 |
anchor_title = gr.Textbox(label="Anchor Title", placeholder="Senior Correspondent")
|
| 928 |
+
|
| 929 |
ticker_text = gr.Textbox(
|
| 930 |
label="π° Ticker Text (scrolls at bottom)",
|
| 931 |
+
placeholder="Breaking News: Enter your headline hereβ¦ | More updates coming soonβ¦",
|
| 932 |
)
|
| 933 |
+
a_logo_file = gr.File(label="π€ Upload Channel Logo", file_types=["image"], type="filepath")
|
| 934 |
a_accent_hex = gr.ColorPicker(value="#052680", label="Accent / Brand Color")
|
| 935 |
+
|
| 936 |
+
anchor_btn = gr.Button("πΊ Process Anchor Video", variant="primary", size="lg",
|
| 937 |
+
elem_classes=["anchor-btn"])
|
| 938 |
+
|
| 939 |
with gr.Column(scale=1):
|
| 940 |
anchor_video_out = gr.Video(label="πΊ Processed Reel", height=560)
|
| 941 |
anchor_status = gr.Markdown()
|
| 942 |
|
| 943 |
bg_choice.change(
|
| 944 |
+
lambda c: gr.update(visible=c == "Pexels"),
|
| 945 |
+
inputs=bg_choice, outputs=news_topic,
|
|
|
|
| 946 |
)
|
| 947 |
anchor_btn.click(
|
| 948 |
+
anchor_pipeline,
|
| 949 |
+
inputs=[anchor_video, bg_choice, a_pexels_key, news_topic,
|
| 950 |
+
anchor_name, anchor_title, ticker_text, a_logo_file, a_accent_hex],
|
|
|
|
|
|
|
| 951 |
outputs=[anchor_video_out, anchor_status],
|
| 952 |
)
|
| 953 |
|
| 954 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 955 |
+
# FOOTER
|
| 956 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 957 |
gr.Markdown("""
|
| 958 |
---
|
| 959 |
+
### π Free API keys
|
| 960 |
| Service | Purpose | Link |
|
| 961 |
|---------|---------|------|
|
| 962 |
| **Groq** *(required)* | AI script generation β Llama 3.3-70B | [console.groq.com](https://console.groq.com) |
|
| 963 |
| **Pexels** *(optional)* | Free HD stock video backgrounds | [pexels.com/api](https://www.pexels.com/api/) |
|
| 964 |
|
| 965 |
+
**Secrets**: Set `GROQ_API_KEY` and `PEXELS_API_KEY` as Hugging Face secrets to avoid typing them each time.
|
| 966 |
+
**Persistent files**: Upload your logo and outro video to the Space's `files/` folder (via the "Files" tab) β they will be automatically loaded if named `logo.png` and `exit.mp4`.
|
| 967 |
""")
|
| 968 |
|
|
|
|
|
|
|
| 969 |
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 970 |
+
print("β UI built, launching Gradio serverβ¦", flush=True)
|
| 971 |
+
demo.launch()
|