Update app.py
Browse files
app.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
-
AI Reels Maker β HuggingFace Space v3.
|
| 3 |
=========================================
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
β’ Robust Edge TTS with retry & fallback
|
| 11 |
"""
|
| 12 |
|
| 13 |
import sys
|
|
@@ -40,7 +39,7 @@ FONT_BOLD_PATH = "/tmp/Montserrat-Bold.ttf"
|
|
| 40 |
FONT_LIGHT_PATH = "/tmp/Montserrat-Regular.ttf"
|
| 41 |
|
| 42 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
-
# VOICE CONFIGS
|
| 44 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
EDGE_VOICES = {
|
| 46 |
"Aria (US Female)" : "en-US-AriaNeural",
|
|
@@ -234,7 +233,7 @@ def generate_script(
|
|
| 234 |
return lines[:num_points]
|
| 235 |
|
| 236 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
-
# AUDIO β Edge TTS (
|
| 238 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
async def _edge_tts_save(text: str, voice: str, path: str) -> None:
|
| 240 |
import edge_tts
|
|
@@ -246,16 +245,18 @@ def generate_audio_edge(text: str, voice_key: str) -> str:
|
|
| 246 |
if len(text.strip()) < 5:
|
| 247 |
raise ValueError(f"Text too short: '{text}'")
|
| 248 |
|
|
|
|
| 249 |
voice = EDGE_VOICES.get(voice_key)
|
| 250 |
if voice is None:
|
| 251 |
-
print(f"[TTS]
|
| 252 |
voice = EDGE_VOICES[DEFAULT_VOICE_KEY]
|
|
|
|
|
|
|
| 253 |
|
| 254 |
-
print(f"[TTS] Using voice ID: {voice} (key: {voice_key})")
|
| 255 |
print(f"[TTS] Text snippet: {text[:200]}... (total chars: {len(text)})")
|
| 256 |
|
| 257 |
out = tempfile.mktemp(suffix=".mp3")
|
| 258 |
-
# Try selected voice first, then fallback to default voice
|
| 259 |
for attempt, try_voice in enumerate([voice, EDGE_VOICES[DEFAULT_VOICE_KEY]]):
|
| 260 |
if attempt > 0:
|
| 261 |
print(f"[TTS] Retry with fallback voice: {try_voice}")
|
|
@@ -299,7 +300,8 @@ def generate_audio_kokoro(text: str, voice_key: str) -> str:
|
|
| 299 |
if _kokoro_pipeline is None:
|
| 300 |
from kokoro import KPipeline
|
| 301 |
_kokoro_pipeline = KPipeline(lang_code="a")
|
| 302 |
-
voice
|
|
|
|
| 303 |
chunks = [a for _, _, a in _kokoro_pipeline(text, voice=voice, speed=1.05)]
|
| 304 |
out = tempfile.mktemp(suffix=".wav")
|
| 305 |
sf.write(out, np.concatenate(chunks), 24000)
|
|
@@ -347,7 +349,8 @@ def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
|
| 347 |
timeout=20,
|
| 348 |
)
|
| 349 |
resp.raise_for_status()
|
| 350 |
-
|
|
|
|
| 351 |
vid_id = vid.get("id")
|
| 352 |
if vid_id in used_ids:
|
| 353 |
continue
|
|
@@ -371,14 +374,23 @@ def _fetch_one_video(query: str, api_key: str, used_ids: set) -> str | None:
|
|
| 371 |
return None
|
| 372 |
|
| 373 |
def fetch_bg_videos(sentences: list[str], api_key: str, count: int = 3) -> list[str]:
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
for q in queries:
|
| 378 |
p = _fetch_one_video(q, api_key, used_ids)
|
| 379 |
if p:
|
| 380 |
paths.append(p)
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
return paths
|
| 383 |
|
| 384 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -413,7 +425,7 @@ def paste_logo(base: Image.Image, logo: Image.Image | None, position: str = "top
|
|
| 413 |
return out
|
| 414 |
|
| 415 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 416 |
-
# TEXT FRAME RENDERER
|
| 417 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 418 |
def render_text_frame(
|
| 419 |
text: str, width: int, height: int,
|
|
@@ -421,15 +433,17 @@ def render_text_frame(
|
|
| 421 |
logo_pos: str = "top-right",
|
| 422 |
accent_color: tuple = (124, 58, 237),
|
| 423 |
) -> np.ndarray:
|
| 424 |
-
|
|
|
|
| 425 |
draw = ImageDraw.Draw(img)
|
| 426 |
font = get_font(60, bold=True)
|
| 427 |
|
|
|
|
| 428 |
words, lines, cur = text.split(), [], []
|
| 429 |
for word in words:
|
| 430 |
test = " ".join(cur + [word])
|
| 431 |
bbox = draw.textbbox((0, 0), test, font=font)
|
| 432 |
-
if bbox[2] - bbox[0] > width * 0.
|
| 433 |
lines.append(" ".join(cur))
|
| 434 |
cur = [word]
|
| 435 |
else:
|
|
@@ -437,26 +451,14 @@ def render_text_frame(
|
|
| 437 |
if cur:
|
| 438 |
lines.append(" ".join(cur))
|
| 439 |
|
| 440 |
-
line_h
|
| 441 |
-
|
| 442 |
-
pad_v, pad_h = 44, 24
|
| 443 |
-
box_y1 = max(pad_h, (height - total_text_h) // 2 - pad_v)
|
| 444 |
-
box_y2 = min(height - pad_h, box_y1 + total_text_h + pad_v * 2)
|
| 445 |
-
|
| 446 |
-
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
| 447 |
-
od = ImageDraw.Draw(overlay)
|
| 448 |
-
od.rounded_rectangle([20, box_y1, width - 20, box_y2], radius=22, fill=(6, 6, 20, 190))
|
| 449 |
-
od.rectangle([20, box_y1, 29, box_y2], fill=(*accent_color, 245))
|
| 450 |
-
img = Image.alpha_composite(img, overlay)
|
| 451 |
-
draw = ImageDraw.Draw(img)
|
| 452 |
|
| 453 |
-
y = box_y1 + pad_v
|
| 454 |
for line in lines:
|
| 455 |
-
bbox
|
| 456 |
text_w = bbox[2] - bbox[0]
|
| 457 |
-
x
|
| 458 |
-
|
| 459 |
-
draw.text((x + dx, y + dy), line, font=font, fill=(0, 0, 0, 170))
|
| 460 |
draw.text((x, y), line, font=font, fill=(255, 255, 255, 255))
|
| 461 |
y += line_h
|
| 462 |
|
|
@@ -529,7 +531,8 @@ def create_reel(
|
|
| 529 |
loops = int(np.ceil(seg_dur / bg.duration)) + 1
|
| 530 |
bg = mpe.concatenate_videoclips([bg] * loops)
|
| 531 |
bg = bg.subclip(0, seg_dur)
|
| 532 |
-
|
|
|
|
| 533 |
seg = mpe.CompositeVideoClip([bg, dark]).set_start(start)
|
| 534 |
bg_segs.append(seg)
|
| 535 |
continue
|
|
@@ -540,7 +543,7 @@ def create_reel(
|
|
| 540 |
|
| 541 |
bg_layer = mpe.CompositeVideoClip(bg_segs, size=(W, H)).set_duration(total_dur)
|
| 542 |
|
| 543 |
-
# Text overlays
|
| 544 |
text_clips = []
|
| 545 |
for i, sentence in enumerate(sentences):
|
| 546 |
arr = render_text_frame(sentence, W, H, logo, logo_pos, accent_color)
|
|
@@ -559,7 +562,7 @@ def create_reel(
|
|
| 559 |
return out
|
| 560 |
|
| 561 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 562 |
-
# NEWS ANCHOR MODE (simplified
|
| 563 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 564 |
def _make_studio_bg(W: int, H: int, dark: bool, accent_color: tuple) -> np.ndarray:
|
| 565 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
|
@@ -876,7 +879,7 @@ with gr.Blocks(title="π¬ AI Reels Maker", css=CSS, theme=gr.themes.Soft()) as
|
|
| 876 |
logo_file = gr.File(label="π€ Upload Logo (PNG/JPG)", file_types=["image"], type="filepath")
|
| 877 |
logo_pos = gr.Dropdown(choices=["top-right","top-left","bottom-right","bottom-left"], value="top-right", label="Logo Position")
|
| 878 |
accent_hex = gr.ColorPicker(value="#d4af37", label="Accent Color")
|
| 879 |
-
show_caption_bg = gr.Checkbox(label="Show caption background", value=
|
| 880 |
outro_video = gr.File(label="π¬ Optional Outro Video (plays after the reel)", file_types=["video"], type="filepath")
|
| 881 |
|
| 882 |
num_points = gr.Slider(6, 10, value=8, step=1, label="π Number of points (8 = ~55s reel)")
|
|
|
|
| 1 |
"""
|
| 2 |
+
AI Reels Maker β HuggingFace Space v3.3
|
| 3 |
=========================================
|
| 4 |
+
- Clean captions (no background, no shadow)
|
| 5 |
+
- Reliable Pexels background videos
|
| 6 |
+
- Voice selection works (Edge TTS & Kokoro)
|
| 7 |
+
- Optional outro video
|
| 8 |
+
- Factual, CTAβenforced prompts
|
| 9 |
+
- Groq & Pexels API secrets support
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import sys
|
|
|
|
| 39 |
FONT_LIGHT_PATH = "/tmp/Montserrat-Regular.ttf"
|
| 40 |
|
| 41 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
# VOICE CONFIGS (plain ASCII keys)
|
| 43 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
EDGE_VOICES = {
|
| 45 |
"Aria (US Female)" : "en-US-AriaNeural",
|
|
|
|
| 233 |
return lines[:num_points]
|
| 234 |
|
| 235 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 236 |
+
# AUDIO β Edge TTS (with voice selection debug)
|
| 237 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 238 |
async def _edge_tts_save(text: str, voice: str, path: str) -> None:
|
| 239 |
import edge_tts
|
|
|
|
| 245 |
if len(text.strip()) < 5:
|
| 246 |
raise ValueError(f"Text too short: '{text}'")
|
| 247 |
|
| 248 |
+
# Get voice ID from key; if not found, use default
|
| 249 |
voice = EDGE_VOICES.get(voice_key)
|
| 250 |
if voice is None:
|
| 251 |
+
print(f"[TTS] WARNING: Voice key '{voice_key}' not found. Using default '{DEFAULT_VOICE_KEY}'")
|
| 252 |
voice = EDGE_VOICES[DEFAULT_VOICE_KEY]
|
| 253 |
+
else:
|
| 254 |
+
print(f"[TTS] Using voice: {voice} (key: {voice_key})")
|
| 255 |
|
|
|
|
| 256 |
print(f"[TTS] Text snippet: {text[:200]}... (total chars: {len(text)})")
|
| 257 |
|
| 258 |
out = tempfile.mktemp(suffix=".mp3")
|
| 259 |
+
# Try selected voice first, then fallback to default voice if it fails
|
| 260 |
for attempt, try_voice in enumerate([voice, EDGE_VOICES[DEFAULT_VOICE_KEY]]):
|
| 261 |
if attempt > 0:
|
| 262 |
print(f"[TTS] Retry with fallback voice: {try_voice}")
|
|
|
|
| 300 |
if _kokoro_pipeline is None:
|
| 301 |
from kokoro import KPipeline
|
| 302 |
_kokoro_pipeline = KPipeline(lang_code="a")
|
| 303 |
+
voice = KOKORO_VOICES.get(voice_key, "af_heart")
|
| 304 |
+
print(f"[Kokoro] Using voice: {voice}")
|
| 305 |
chunks = [a for _, _, a in _kokoro_pipeline(text, voice=voice, speed=1.05)]
|
| 306 |
out = tempfile.mktemp(suffix=".wav")
|
| 307 |
sf.write(out, np.concatenate(chunks), 24000)
|
|
|
|
| 349 |
timeout=20,
|
| 350 |
)
|
| 351 |
resp.raise_for_status()
|
| 352 |
+
data = resp.json()
|
| 353 |
+
for vid in data.get("videos", []):
|
| 354 |
vid_id = vid.get("id")
|
| 355 |
if vid_id in used_ids:
|
| 356 |
continue
|
|
|
|
| 374 |
return None
|
| 375 |
|
| 376 |
def fetch_bg_videos(sentences: list[str], api_key: str, count: int = 3) -> list[str]:
|
| 377 |
+
if not api_key:
|
| 378 |
+
print("[pexels] No API key provided β skipping background videos.")
|
| 379 |
+
return []
|
| 380 |
+
queries = _extract_queries(sentences, count=count)
|
| 381 |
+
print(f"[pexels] Searching for videos with queries: {queries}")
|
| 382 |
+
used_ids = set()
|
| 383 |
+
paths = []
|
| 384 |
for q in queries:
|
| 385 |
p = _fetch_one_video(q, api_key, used_ids)
|
| 386 |
if p:
|
| 387 |
paths.append(p)
|
| 388 |
+
if len(paths) >= count:
|
| 389 |
+
break
|
| 390 |
+
if not paths:
|
| 391 |
+
print("[pexels] WARNING: No videos found. Using gradient fallback.")
|
| 392 |
+
else:
|
| 393 |
+
print(f"[pexels] Successfully fetched {len(paths)} videos: {paths}")
|
| 394 |
return paths
|
| 395 |
|
| 396 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 425 |
return out
|
| 426 |
|
| 427 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 428 |
+
# TEXT FRAME RENDERER (NO background, NO shadow)
|
| 429 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 430 |
def render_text_frame(
|
| 431 |
text: str, width: int, height: int,
|
|
|
|
| 433 |
logo_pos: str = "top-right",
|
| 434 |
accent_color: tuple = (124, 58, 237),
|
| 435 |
) -> np.ndarray:
|
| 436 |
+
"""Render text with NO background and NO shadow β just clean text on video."""
|
| 437 |
+
img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # fully transparent
|
| 438 |
draw = ImageDraw.Draw(img)
|
| 439 |
font = get_font(60, bold=True)
|
| 440 |
|
| 441 |
+
# Word wrap to fit 90% of width (no background, so more space)
|
| 442 |
words, lines, cur = text.split(), [], []
|
| 443 |
for word in words:
|
| 444 |
test = " ".join(cur + [word])
|
| 445 |
bbox = draw.textbbox((0, 0), test, font=font)
|
| 446 |
+
if bbox[2] - bbox[0] > width * 0.90 and cur:
|
| 447 |
lines.append(" ".join(cur))
|
| 448 |
cur = [word]
|
| 449 |
else:
|
|
|
|
| 451 |
if cur:
|
| 452 |
lines.append(" ".join(cur))
|
| 453 |
|
| 454 |
+
line_h = 72
|
| 455 |
+
y = (height - (len(lines) * line_h)) // 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
|
|
|
|
| 457 |
for line in lines:
|
| 458 |
+
bbox = draw.textbbox((0, 0), line, font=font)
|
| 459 |
text_w = bbox[2] - bbox[0]
|
| 460 |
+
x = (width - text_w) // 2
|
| 461 |
+
# Draw text directly, no shadow, no background
|
|
|
|
| 462 |
draw.text((x, y), line, font=font, fill=(255, 255, 255, 255))
|
| 463 |
y += line_h
|
| 464 |
|
|
|
|
| 531 |
loops = int(np.ceil(seg_dur / bg.duration)) + 1
|
| 532 |
bg = mpe.concatenate_videoclips([bg] * loops)
|
| 533 |
bg = bg.subclip(0, seg_dur)
|
| 534 |
+
# Darken only if you want, but now text has no background so we keep it lighter
|
| 535 |
+
dark = mpe.ColorClip((W, H), color=[0, 0, 0]).set_opacity(0.35).set_duration(seg_dur)
|
| 536 |
seg = mpe.CompositeVideoClip([bg, dark]).set_start(start)
|
| 537 |
bg_segs.append(seg)
|
| 538 |
continue
|
|
|
|
| 543 |
|
| 544 |
bg_layer = mpe.CompositeVideoClip(bg_segs, size=(W, H)).set_duration(total_dur)
|
| 545 |
|
| 546 |
+
# Text overlays (clean, no background)
|
| 547 |
text_clips = []
|
| 548 |
for i, sentence in enumerate(sentences):
|
| 549 |
arr = render_text_frame(sentence, W, H, logo, logo_pos, accent_color)
|
|
|
|
| 562 |
return out
|
| 563 |
|
| 564 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 565 |
+
# NEWS ANCHOR MODE (simplified)
|
| 566 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 567 |
def _make_studio_bg(W: int, H: int, dark: bool, accent_color: tuple) -> np.ndarray:
|
| 568 |
frame = np.zeros((H, W, 3), dtype=np.uint8)
|
|
|
|
| 879 |
logo_file = gr.File(label="π€ Upload Logo (PNG/JPG)", file_types=["image"], type="filepath")
|
| 880 |
logo_pos = gr.Dropdown(choices=["top-right","top-left","bottom-right","bottom-left"], value="top-right", label="Logo Position")
|
| 881 |
accent_hex = gr.ColorPicker(value="#d4af37", label="Accent Color")
|
| 882 |
+
show_caption_bg = gr.Checkbox(label="Show caption background", value=False, info="Unchecked = no background/shadow (clean text)", visible=False) # hidden because we removed background
|
| 883 |
outro_video = gr.File(label="π¬ Optional Outro Video (plays after the reel)", file_types=["video"], type="filepath")
|
| 884 |
|
| 885 |
num_points = gr.Slider(6, 10, value=8, step=1, label="π Number of points (8 = ~55s reel)")
|