Spaces:
Running
Running
Fix: multi-segment short video - each key point shown with its own image
Browse files
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(
|
| 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,
|
| 572 |
return out_path
|
| 573 |
raise RuntimeError("Không tạo được ảnh")
|
| 574 |
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
if Image is None:
|
| 577 |
raise RuntimeError("Pillow chưa sẵn sàng")
|
| 578 |
W, H = 1080, 1920
|
| 579 |
-
|
|
|
|
| 580 |
try:
|
| 581 |
im = Image.open(img_path).convert("RGB")
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
new_h = target[1]; new_w = int(new_h * im_ratio)
|
| 587 |
else:
|
| 588 |
-
|
| 589 |
-
im = im.resize((
|
| 590 |
-
left = (
|
| 591 |
-
im = im.crop((left, top, left +
|
| 592 |
bg.paste(im, (0, 0))
|
| 593 |
except Exception:
|
| 594 |
pass
|
| 595 |
draw = ImageDraw.Draw(bg)
|
|
|
|
| 596 |
try:
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
except Exception:
|
| 601 |
-
|
| 602 |
-
draw.
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
for
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 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
|
| 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 |
-
|
| 651 |
-
|
| 652 |
-
script = _short_script(post)
|
| 653 |
|
| 654 |
-
#
|
| 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 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 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 {
|