bep40 commited on
Commit
ed7fd4e
·
verified ·
1 Parent(s): b22729d

Fix: multi-segment short video - each key point shown with its own image

Browse files
Files changed (1) hide show
  1. ai_ext.py +163 -75
ai_ext.py CHANGED
@@ -547,14 +547,22 @@ def _generate_tts_gtts(text: str, out_path: str):
547
  gTTS(text, lang="vi").save(out_path)
548
 
549
 
550
- # ===== SHORT VIDEO GENERATION =====
551
  def _download_image(url, fallback_topic, out_path):
 
552
  if url:
 
 
 
 
 
553
  try:
554
- r = requests.get(url, headers=HEADERS, timeout=15)
555
  if r.status_code == 200 and len(r.content) > 1000:
556
  with open(out_path, "wb") as f:
557
  f.write(r.content)
 
 
558
  return out_path
559
  except Exception:
560
  pass
@@ -568,116 +576,196 @@ def _download_image(url, fallback_topic, out_path):
568
  except Exception:
569
  pass
570
  if Image:
571
- Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
572
  return out_path
573
  raise RuntimeError("Không tạo được ảnh")
574
 
575
- def _make_short_frame(post, img_path, out_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  if Image is None:
577
  raise RuntimeError("Pillow chưa sẵn sàng")
578
  W, H = 1080, 1920
579
- bg = Image.new("RGB", (W, H), (14, 14, 14))
 
580
  try:
581
  im = Image.open(img_path).convert("RGB")
582
- target = (1080, 860)
583
- im_ratio = im.width / im.height
584
- target_ratio = target[0] / target[1]
585
- if im_ratio > target_ratio:
586
- new_h = target[1]; new_w = int(new_h * im_ratio)
587
  else:
588
- new_w = target[0]; new_h = int(new_w / im_ratio)
589
- im = im.resize((new_w, new_h))
590
- left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
591
- im = im.crop((left, top, left + target[0], top + target[1]))
592
  bg.paste(im, (0, 0))
593
  except Exception:
594
  pass
595
  draw = ImageDraw.Draw(bg)
 
596
  try:
597
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
598
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
599
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
600
  except Exception:
601
- font_title = font_body = font_label = None
602
- draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
603
- draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
604
- title = post.get("title", "")
605
- words = title.split(); lines_t = []; cur = ""
606
- for w in words:
607
- if len(cur) + len(w) + 1 <= 24:
608
- cur = (cur + " " + w).strip()
609
- else:
610
- if cur: lines_t.append(cur)
611
- cur = w
612
- if cur: lines_t.append(cur)
613
- draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
614
- body_text = post.get("text", "")
615
- words_b = body_text.split(); lines_b = []; cur_b = ""
616
- for w in words_b:
617
- if len(cur_b) + len(w) + 1 <= 34:
618
- cur_b = (cur_b + " " + w).strip()
619
- else:
620
- if cur_b: lines_b.append(cur_b)
621
- cur_b = w
622
- if len(lines_b) >= 10:
623
- break
624
- if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
625
- draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
626
  bg.save(out_path, quality=92)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
- def _short_script(post, max_chars=700):
629
- """Build TTS script: title + text (concise, within char limit)."""
630
- txt = _clean_text(post.get("text", ""))
631
- if len(txt) > max_chars:
632
- txt = txt[:max_chars].rsplit(" ", 1)[0] + "."
633
- title = _clean_text(post.get("title", ""))
634
- return f"{title}. {txt}"
635
 
636
  async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
637
- """Generate MP4 short video with TTS. Returns video URL or empty string on failure."""
638
  try:
639
  os.makedirs(SHORTS_DIR, exist_ok=True)
640
  out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
641
- if os.path.exists(out_mp4):
642
  return "/api/ai/short-file/" + post_id
643
 
644
- work = os.path.join(SHORTS_DIR, _safe_name(post_id))
645
  os.makedirs(work, exist_ok=True)
646
- img_path = os.path.join(work, "image.jpg")
647
- frame_path = os.path.join(work, "frame.jpg")
648
- audio_path = os.path.join(work, "voice.mp3")
649
 
650
- _download_image(post.get("img"), post.get("title", "AI news"), img_path)
651
- _make_short_frame(post, img_path, frame_path)
652
- script = _short_script(post)
653
 
654
- # Auto-detect voice if not specified
655
  if voice_id is None:
656
  voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
657
  if speed is None:
658
  speed = TTS_DEFAULT_SPEED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
660
- # Generate TTS
661
- try:
662
- await _generate_tts_edge(script, voice_id, speed, audio_path)
663
- except Exception as e:
664
- print(f"[TTS edge-tts error] {e}, falling back to gTTS")
665
- if gTTS:
666
- _generate_tts_gtts(script, audio_path)
667
- else:
668
- return ""
669
-
670
- # Assemble video
671
- cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
672
- "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
673
- "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
674
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
675
  return "/api/ai/short-file/" + post_id
676
  except Exception as e:
677
  print(f"[short video error] {e}")
678
  return ""
679
 
680
-
681
  # ===== MAKE POST =====
682
  def make_post(title, text, image, source_url, kind, sources=None, images=None):
683
  return {
 
547
  gTTS(text, lang="vi").save(out_path)
548
 
549
 
550
+ # ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) =====
551
  def _download_image(url, fallback_topic, out_path):
552
+ """Download an image (un-proxying our own /api/proxy/img). Falls back to generated image."""
553
  if url:
554
+ u = url
555
+ m = re.search(r'/api/proxy/img\?url=(.+)$', u)
556
+ if m:
557
+ from urllib.parse import unquote
558
+ u = unquote(m.group(1))
559
  try:
560
+ r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15)
561
  if r.status_code == 200 and len(r.content) > 1000:
562
  with open(out_path, "wb") as f:
563
  f.write(r.content)
564
+ if Image:
565
+ Image.open(out_path).verify()
566
  return out_path
567
  except Exception:
568
  pass
 
576
  except Exception:
577
  pass
578
  if Image:
579
+ Image.new("RGB", (1080, 980), (30, 55, 42)).save(out_path)
580
  return out_path
581
  raise RuntimeError("Không tạo được ảnh")
582
 
583
+
584
+ def _split_keypoint_sentences(text, max_points=6):
585
+ """Split summary text into key points: prefer bullet markers, else sentences."""
586
+ text = _clean_text(text)
587
+ parts = re.split(r'\s*•\s*', text)
588
+ good = [p.strip() for p in parts if len(p.strip()) > 20]
589
+ if len(good) >= 2:
590
+ # Explicit bullet points: keep each one as-is (never merge).
591
+ return good[:max_points]
592
+ # Fallback: split into sentences and merge orphan short fragments.
593
+ pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20]
594
+ out = []
595
+ for p in pts:
596
+ if out and len(p) < 40:
597
+ out[-1] = (out[-1] + " " + p).strip()
598
+ else:
599
+ out.append(p)
600
+ return out[:max_points] if out else ([text] if text else [])
601
+
602
+
603
+ def _build_keypoints(post, max_points=6):
604
+ """Return [{text, image}] pairing each key point with its own image."""
605
+ slides = post.get("slides") or []
606
+ images = post.get("images") or ([post.get("img")] if post.get("img") else [])
607
+ images = [i for i in images if i]
608
+ if slides:
609
+ kps = []
610
+ for i, s in enumerate(slides[:max_points]):
611
+ t = _clean_text(s.get("text", ""))
612
+ img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else ""))
613
+ if t:
614
+ kps.append({"text": t, "image": img})
615
+ if kps:
616
+ return kps
617
+ points = _split_keypoint_sentences(post.get("text", ""), max_points)
618
+ kps = []
619
+ for i, t in enumerate(points):
620
+ img = images[i] if i < len(images) else (images[-1] if images else "")
621
+ kps.append({"text": t, "image": img})
622
+ if not kps:
623
+ kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}]
624
+ return kps
625
+
626
+
627
+ def _wrap_text(draw, text, font, max_w):
628
+ words = text.split()
629
+ lines, cur = [], ""
630
+ for w in words:
631
+ test = (cur + " " + w).strip()
632
+ if draw.textlength(test, font=font) <= max_w:
633
+ cur = test
634
+ else:
635
+ if cur:
636
+ lines.append(cur)
637
+ cur = w
638
+ if cur:
639
+ lines.append(cur)
640
+ return lines
641
+
642
+
643
+ def _make_segment_frame(title, point_text, img_path, idx, total, out_path):
644
+ """Render a 1080x1920 vertical frame: image on top, key point text below."""
645
  if Image is None:
646
  raise RuntimeError("Pillow chưa sẵn sàng")
647
  W, H = 1080, 1920
648
+ IMG_H = 980
649
+ bg = Image.new("RGB", (W, H), (12, 14, 18))
650
  try:
651
  im = Image.open(img_path).convert("RGB")
652
+ tr = W / IMG_H
653
+ ir = im.width / im.height
654
+ if ir > tr:
655
+ nh = IMG_H; nw = int(nh * ir)
 
656
  else:
657
+ nw = W; nh = int(nw / ir)
658
+ im = im.resize((nw, nh))
659
+ left = (nw - W) // 2; top = (nh - IMG_H) // 2
660
+ im = im.crop((left, top, left + W, top + IMG_H))
661
  bg.paste(im, (0, 0))
662
  except Exception:
663
  pass
664
  draw = ImageDraw.Draw(bg)
665
+ draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18))
666
  try:
667
+ f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
668
+ f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46)
669
+ f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52)
670
  except Exception:
671
+ f_label = f_title = f_point = ImageFont.load_default()
672
+ draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label)
673
+ cnt = f"{idx + 1}/{total}"
674
+ draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label)
675
+ y = IMG_H + 86
676
+ for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]:
677
+ draw.text((54, y), ln, fill=(255, 255, 255), font=f_title)
678
+ y += 56
679
+ y += 16
680
+ for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]:
681
+ draw.text((54, y), ln, fill=(225, 230, 235), font=f_point)
682
+ y += 64
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  bg.save(out_path, quality=92)
684
+ return out_path
685
+
686
+
687
+ def _ffmpeg_bin():
688
+ return os.environ.get("FFMPEG_BIN", "ffmpeg")
689
+
690
+
691
+ def _audio_duration(path):
692
+ try:
693
+ out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr
694
+ m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out)
695
+ if m:
696
+ h, mi, s = m.groups()
697
+ return int(h) * 3600 + int(mi) * 60 + float(s)
698
+ except Exception:
699
+ pass
700
+ return 0.0
701
 
 
 
 
 
 
 
 
702
 
703
  async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
704
+ """Generate a multi-segment MP4 short: each key point shown with its OWN image + narration."""
705
  try:
706
  os.makedirs(SHORTS_DIR, exist_ok=True)
707
  out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
708
+ if os.path.exists(out_mp4) and voice_id is None and speed is None:
709
  return "/api/ai/short-file/" + post_id
710
 
711
+ work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work")
712
  os.makedirs(work, exist_ok=True)
 
 
 
713
 
714
+ title = _clean_text(post.get("title", "")) or "VNEWS"
715
+ kps = _build_keypoints(post)
 
716
 
717
+ # Resolve voice
718
  if voice_id is None:
719
  voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
720
  if speed is None:
721
  speed = TTS_DEFAULT_SPEED
722
+ vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
723
+
724
+ seg_files = []
725
+ ff = _ffmpeg_bin()
726
+ for i, kp in enumerate(kps):
727
+ img_path = os.path.join(work, f"img{i}.jpg")
728
+ frame_path = os.path.join(work, f"frame{i}.jpg")
729
+ audio_path = os.path.join(work, f"voice{i}.mp3")
730
+ seg_mp4 = os.path.join(work, f"seg{i}.mp4")
731
+ _download_image(kp.get("image", ""), title, img_path)
732
+ _make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path)
733
+ narration = (title + ". " + kp["text"]) if i == 0 else kp["text"]
734
+ try:
735
+ await _generate_tts_edge(narration, voice_id, speed, audio_path)
736
+ except Exception as e:
737
+ print(f"[TTS edge-tts error] {e}, falling back to gTTS")
738
+ if gTTS:
739
+ _generate_tts_gtts(narration, audio_path)
740
+ else:
741
+ return ""
742
+ dur = _audio_duration(audio_path)
743
+ if dur < 1.0:
744
+ dur = 2.0
745
+ cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
746
+ "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
747
+ "-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
748
+ "-vf", "scale=1080:1920", "-r", "25", seg_mp4]
749
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
750
+ seg_files.append(seg_mp4)
751
+
752
+ if not seg_files:
753
+ return ""
754
+ if len(seg_files) == 1:
755
+ os.replace(seg_files[0], out_mp4)
756
+ return "/api/ai/short-file/" + post_id
757
 
758
+ listfile = os.path.join(work, "concat.txt")
759
+ with open(listfile, "w", encoding="utf-8") as f:
760
+ f.write("\n".join(f"file '{s}'" for s in seg_files))
761
+ cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile,
762
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4]
763
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
 
 
 
 
 
 
 
 
 
764
  return "/api/ai/short-file/" + post_id
765
  except Exception as e:
766
  print(f"[short video error] {e}")
767
  return ""
768
 
 
769
  # ===== MAKE POST =====
770
  def make_post(title, text, image, source_url, kind, sources=None, images=None):
771
  return {