yonagush commited on
Commit
1da4dfb
Β·
verified Β·
1 Parent(s): a93c01d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +262 -310
app.py CHANGED
@@ -1,14 +1,16 @@
1
  """
2
- AI Reels Maker β€” HuggingFace Space v2.0
3
  =========================================
4
  Professional viral reel generator with:
5
- β€’ 45-60 second reels (8+ sentences, 18-20 words each)
6
  β€’ 2-3 stitched background videos from Pexels for variety
 
 
7
  β€’ Accurate voice selection β€” chosen voice is always used
8
- β€’ Clean script prompts β€” no fake "Breaking News" labels
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 about the given topic.\n"
87
- "Format: one compelling hook sentence, then exactly 5 items numbered 5 down to 1.\n"
88
- "Each line (including the hook) must be 15-20 words β€” rich, specific, and punchy.\n"
89
- "Output ONLY {n} lines total, one per line. No extra text, no headers."
 
90
  ),
91
  "Fact": (
92
- "Create a surprising FACTS reel script about the given topic.\n"
93
- "Open with the most jaw-dropping fact as a hook.\n"
94
- "Every one of the {n} lines is a standalone mind-blowing fact, 15-20 words each.\n"
 
 
95
  "Output ONLY {n} lines, one per line. No extra text."
96
  ),
97
  "Ranking": (
98
- "Create a RANKING reel script about the given topic, ordered from best to worst.\n"
99
- "Start with a bold, opinionated hook. Each item has a clear rank and vivid detail.\n"
100
- "Every line: 15-20 words, confident and engaging.\n"
 
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 about the given topic.\n"
105
- "Hook first, then clear numbered steps (Step 1: …, Step 2: …, etc.).\n"
106
- "Every line: 15-20 words, clear, actionable, and beginner-friendly.\n"
107
  "Output ONLY {n} lines, one per line. No extra text."
108
  ),
109
  "Statistics": (
110
- "Create a STATISTICS reel script with striking data points about the given topic.\n"
111
- "Lead with the most mind-blowing statistic. Each line builds on the last.\n"
112
- "Every line: 15-20 words, include a specific number, percentage, or figure.\n"
 
113
  "Output ONLY {n} lines, one per line. No extra text."
114
  ),
115
  "Quiz": (
116
- "Create an interactive QUIZ reel script about the given topic.\n"
117
- "Open with 'Can you get all of these right?' then pose {n} quiz questions.\n"
118
- "Close the last line with a CTA: 'Comment your score below!'.\n"
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 given topic.\n"
123
- "Each line is one powerful, iconic quote followed by β€” Author Name.\n"
124
- "Choose quotes that are inspiring, surprising, or thought-provoking.\n"
125
  "Output ONLY {n} quote lines, one per line. No extra text."
126
  ),
127
  "Product Demo": (
128
- "Create a PRODUCT/IDEA DEMO reel script that educates without being salesy.\n"
129
  "Hook with the core problem, then explain the solution step by step.\n"
130
- "Close with a compelling call-to-action. Every line: 15-20 words.\n"
131
  "Output ONLY {n} lines, one per line. No extra text."
132
  ),
133
  "Joke": (
134
- "Create a COMEDY reel script β€” build-up plus punchline format.\n"
135
- "Either a series of related jokes or one escalating comedic bit.\n"
136
- "Every line: 15-18 words, tight timing, punchy ending.\n"
137
  "Output ONLY {n} lines, one per line. No extra text."
138
  ),
139
  "Blog to Reel": (
140
- "Distill this content into a viral short-form reel.\n"
141
- "Extract the {n} most valuable insights β€” hook first, strong CTA last.\n"
142
- "Every line: 15-20 words, conversational tone, zero jargon.\n"
143
- "Output ONLY {n} lines, one per line. No extra text."
144
  ),
145
  "Custom Prompt": (
146
- "You are a viral content creator. Using the content/topic below, "
147
- "write exactly {n} engaging sentences for a vertical video reel.\n"
148
- "Every sentence: 15-20 words. Output ONLY {n} lines, one per line. No extra text."
 
 
 
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 # lazy β€” avoid slow startup
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 (groq lazy-loaded)
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 # lazy
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] # remove blank lines
232
  return lines[:num_points]
233
 
234
  # ─────────────────────────────────────────────────────────────────────────────
235
- # AUDIO β€” Edge TTS (edge_tts lazy-loaded)
236
  # ─────────────────────────────────────────────────────────────────────────────
237
  async def _edge_tts_save(text: str, voice: str, path: str) -> None:
238
- import edge_tts # lazy
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 (optional, lazy-loaded)
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 # noqa
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 # lazy
280
  if not _check_kokoro():
281
- raise ImportError("Kokoro TTS not installed β€” switching to Edge TTS")
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 1–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,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
- # Divide into `count` buckets and take up to 3 words per query
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 = i / H
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], # 0, 1, 2, or 3 video paths
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 # lazy β€” avoid slow startup
499
-
500
  W, H = VIDEO_W, VIDEO_H
501
 
502
  try:
503
- h = accent_hex.lstrip("#")
504
- accent_color = tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
505
  except Exception:
506
  accent_color = (124, 58, 237)
507
 
508
- logo = load_logo(logo_path)
509
- audio = mpe.AudioFileClip(audio_path)
510
  total_dur = audio.duration
511
- n_sents = len(sentences)
512
- dur_each = total_dur / n_sents
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
- # ── Build multi-video background ─────────────────────────────────────────
521
- num_bg = max(1, len(bg_video_paths))
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 = mpe.concatenate_videoclips([bg] * loops)
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 = mpe.CompositeVideoClip([bg, dark]).set_start(start)
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
- # ── Text overlay clips ────────────────────────────────────────────────────
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
- mpe.ImageClip(arr)
555
- .set_start(i * dur_each)
556
- .set_duration(dur_each)
557
- .crossfadein(0.30)
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 = tempfile.mktemp(suffix=".mp4")
565
- final.write_videofile(
566
- out,
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 β€” helpers
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), name.upper(), font=font_bold, fill=(255, 255, 255, 255))
602
  if title:
603
- draw.text((32, bar_y + 50), title, font=font_light, fill=(220, 220, 220, 220))
604
 
605
  def _draw_ticker(draw, font, ticker_text, W, H, frame_num, scroll_speed=4):
606
  ticker_h = 50
607
- bar_y = H - ticker_h
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
- (offset, bar_y + 8),
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 = accent_hex.lstrip("#")
632
- accent_color = tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
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 = clip.duration
640
 
641
- font_bold = get_font(36, bold=True)
642
- font_light = get_font(28, bold=False)
643
  ticker_font = get_font(24, bold=False)
644
- logo = load_logo(logo_path)
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 = _fetch_one_video(kw, pexels_key, set())
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 = mpe.concatenate_videoclips([pex] * loops)
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 = np.zeros((H, W, 3), dtype=np.uint8)
673
  frame[:, :] = accent_color
674
- bg_clip = mpe.ImageClip(frame).set_duration(dur)
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 = Image.fromarray(frame).convert("RGBA")
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 = composite.fl(add_overlay, apply_to=["video"])
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
- out, codec="libx264", audio_codec="aac",
709
- fps=min(FPS, int(clip.fps or FPS)),
710
- preset="ultrafast", threads=4, logger=None,
711
- )
712
  return out
713
 
714
  # ─────────────────────────────────────────────────────────────────────────────
715
- # MAIN PIPELINE β€” Reel Generator
716
- # ─────────────────────────────────────────────────────────────────────────────
717
- def generate_reel_pipeline(
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 None, "❌ **Groq API key required.** Free at [console.groq.com](https://console.groq.com)"
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 None, (
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) < 15:
747
- return None, "❌ Please enter a URL or a topic (at least a few words)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
 
749
- n_pts = int(num_points)
750
- progress(0.15, desc=f"��️ Writing '{reel_type}' script…")
751
- sentences = generate_script(content, groq_key, reel_type, n_pts)
 
752
  if not sentences:
753
- return None, "❌ Script generation failed. Check your Groq API key and try again."
754
 
755
  full_script = " ".join(sentences)
756
- word_count = len(full_script.split())
757
- est_secs = round(word_count / 150 * 60)
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
- voice_label = kokoro_voice if using_kokoro else edge_voice
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}", flush=True)
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.55, desc="🎬 Fetching background videos…")
782
  bg_videos = fetch_bg_videos(sentences, pexels_key, count=3)
783
 
784
- progress(0.75, desc="🎞️ Assembling reel…")
785
- logo_path = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
786
- video_path = create_reel(sentences, audio_path, bg_videos, logo_path, logo_pos, accent_hex)
787
-
788
- script_md = "\n\n".join(f"**{i + 1}.** {s}" for i, s in enumerate(sentences))
789
- progress(1.0, desc="βœ… Done!")
790
- return video_path, (
791
- f"## βœ… Reel Ready!\n\n"
792
- f"**Type:** {reel_type} &nbsp;|&nbsp; **Voice:** {voice_label} &nbsp;|&nbsp; "
793
- f"**{len(sentences)} lines Β· {word_count} words Β· ~{est_secs}s**\n\n"
794
- f"**Script:**\n\n{script_md}"
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
- bg_choice,
807
- pexels_key,
808
- news_topic,
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 = logo_file if isinstance(logo_file, str) else (logo_file.name if logo_file else None)
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 / blog URL, or type a topic like 'Top AI tools 2025'…",
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
- label="πŸ”‘ Groq API Key",
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 = gr.Dropdown(
893
- choices=list(EDGE_VOICES.keys()),
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 = gr.File(label="πŸ“€ Upload Logo (PNG/JPG)", file_types=["image"])
907
- logo_pos = gr.Dropdown(
908
- choices=["top-right", "top-left", "bottom-right", "bottom-left"],
909
- value="top-right",
910
- label="Logo Position",
911
- )
912
- accent_hex = gr.ColorPicker(value="#7c3aed", label="Accent Color")
913
 
914
- num_points = gr.Slider(
915
- 6, 10, value=8, step=1,
916
- label="πŸ“Š Number of points (8 = ~55s reel)",
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
- fn=lambda e: (gr.update(visible=e == "Edge TTS"), gr.update(visible=e == "Kokoro TTS")),
931
- inputs=tts_engine,
932
- outputs=[edge_voice, kokoro_voice],
933
  )
934
  gen_btn.click(
935
- fn=generate_reel_pipeline,
936
- inputs=[
937
- url_input, groq_key, pexels_key,
938
- reel_type, tts_engine, edge_voice, kokoro_voice,
939
- num_points, logo_file, logo_pos, accent_hex,
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 **anchor / presenter video** and transform it into a "
950
- "professional broadcast-style reel with lower-third, ticker, and branded background."
 
 
 
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 = gr.Textbox(
964
- label="πŸŽ₯ Pexels API Key",
965
- type="password",
966
- placeholder="Required if Background = Pexels",
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
- anchor_btn = gr.Button(
983
- "πŸ“Ί Process Anchor Video", variant="primary", size="lg",
984
- elem_classes=["anchor-btn"],
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
- fn=lambda c: gr.update(visible=c == "Pexels"),
992
- inputs=bg_choice,
993
- outputs=news_topic,
994
  )
995
  anchor_btn.click(
996
- fn=anchor_pipeline,
997
- inputs=[
998
- anchor_video, bg_choice, a_pexels_key, news_topic,
999
- anchor_name, anchor_title, ticker_text, a_logo_file, a_accent_hex,
1000
- ],
1001
  outputs=[anchor_video_out, anchor_status],
1002
  )
1003
 
1004
- # ── Footer ───────────────────────────────────────────────────────────────
 
 
1005
  gr.Markdown("""
1006
  ---
1007
- ### πŸ”‘ Free API Keys
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
- **Tips for best results:** Use 8 points for a ~55-second reel Β· Paste article text directly for highest quality scripts
 
1014
  """)
1015
 
1016
- print("βœ“ UI built, launching Gradio server…", flush=True)
1017
-
1018
  os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
1019
- demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft(), css=CSS)
 
 
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()