Restore Space to revision 33c3dda
Browse files- ai_patch.py +32 -146
ai_patch.py
CHANGED
|
@@ -518,119 +518,8 @@ def _make_short_frame_full(post, img_path, out_path):
|
|
| 518 |
bg.save(out_path, quality=92)
|
| 519 |
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
def _summary_segments_from_post(post, max_segments=7):
|
| 524 |
-
"""Extract clean summary segments. No old context; each bullet/paragraph becomes a scene."""
|
| 525 |
-
raw = _clean(post.get('text') or post.get('title') or '')
|
| 526 |
-
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 527 |
-
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
| 528 |
-
# Prefer bullet points if available
|
| 529 |
-
lines = []
|
| 530 |
-
for ln in raw.splitlines():
|
| 531 |
-
ln = _clean(re.sub(r'^[•\-\*\d\.\)\s]+', '', ln))
|
| 532 |
-
if not ln: continue
|
| 533 |
-
if len(ln) >= 18 and not ln.lower().startswith(('điểm chính', 'tiêu đề', 'sapo')):
|
| 534 |
-
lines.append(ln)
|
| 535 |
-
if len(lines) < 2:
|
| 536 |
-
# split into sentences/clauses
|
| 537 |
-
lines = []
|
| 538 |
-
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 539 |
-
s = _clean(s)
|
| 540 |
-
if len(s) >= 25:
|
| 541 |
-
lines.append(s)
|
| 542 |
-
segs = _dedupe_units(lines, max_units=max_segments)
|
| 543 |
-
if not segs:
|
| 544 |
-
segs = [post.get('title','Bản tin VNEWS')]
|
| 545 |
-
return segs[:max_segments]
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 549 |
-
"""Create one 9:16 frame for a single summary segment."""
|
| 550 |
-
if Image is None:
|
| 551 |
-
return _make_short_frame_full(post, img_path, out_path)
|
| 552 |
-
W, H = 1080, 1920
|
| 553 |
-
bg = Image.new('RGB', (W, H), (10, 10, 10))
|
| 554 |
-
try:
|
| 555 |
-
im = Image.open(img_path).convert('RGB')
|
| 556 |
-
# background blur/cover approximation: cover whole canvas
|
| 557 |
-
ratio = im.width / max(1, im.height)
|
| 558 |
-
target_ratio = W / H
|
| 559 |
-
if ratio > target_ratio:
|
| 560 |
-
nh = H; nw = int(nh * ratio)
|
| 561 |
-
else:
|
| 562 |
-
nw = W; nh = int(nw / ratio)
|
| 563 |
-
im2 = im.resize((nw, nh))
|
| 564 |
-
left=(nw-W)//2; top=(nh-H)//2
|
| 565 |
-
im2 = im2.crop((left, top, left+W, top+H))
|
| 566 |
-
bg.paste(im2, (0,0))
|
| 567 |
-
# dark overlay
|
| 568 |
-
overlay = Image.new('RGB',(W,H),(0,0,0))
|
| 569 |
-
bg = Image.blend(bg, overlay, 0.48)
|
| 570 |
-
# hero image block top
|
| 571 |
-
hero_h = 720
|
| 572 |
-
ratio = im.width / max(1, im.height)
|
| 573 |
-
target_ratio = W / hero_h
|
| 574 |
-
if ratio > target_ratio:
|
| 575 |
-
nh = hero_h; nw = int(nh * ratio)
|
| 576 |
-
else:
|
| 577 |
-
nw = W; nh = int(nw / ratio)
|
| 578 |
-
hero = im.resize((nw, nh))
|
| 579 |
-
left=(nw-W)//2; top=(nh-hero_h)//2
|
| 580 |
-
hero = hero.crop((left, top, left+W, top+hero_h))
|
| 581 |
-
bg.paste(hero,(0,0))
|
| 582 |
-
except Exception:
|
| 583 |
-
pass
|
| 584 |
-
draw = ImageDraw.Draw(bg)
|
| 585 |
-
try:
|
| 586 |
-
font_brand = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 34)
|
| 587 |
-
font_label = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 32)
|
| 588 |
-
font_seg = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 58)
|
| 589 |
-
font_title = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 34)
|
| 590 |
-
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28)
|
| 591 |
-
except Exception:
|
| 592 |
-
font_brand = font_label = font_seg = font_title = font_small = None
|
| 593 |
-
# gradient/dark content panel
|
| 594 |
-
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 595 |
-
# progress dots
|
| 596 |
-
dot_y=742; dot_x=48
|
| 597 |
-
for i in range(total):
|
| 598 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 599 |
-
draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
|
| 600 |
-
draw.text((48, 780), 'VNEWS AI SHORT', fill=(110, 231, 143), font=font_brand)
|
| 601 |
-
topic = emotion or 'Tin tức'
|
| 602 |
-
draw.rounded_rectangle((48, 834, 260, 880), radius=20, fill=(28,70,45))
|
| 603 |
-
draw.text((66, 842), f'Đoạn {idx+1}/{total}', fill=(235,235,235), font=font_small)
|
| 604 |
-
y = 940
|
| 605 |
-
maxw = W - 96
|
| 606 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
|
| 607 |
-
draw.text((48, y), ln, fill=(255,255,255), font=font_seg)
|
| 608 |
-
y += 74
|
| 609 |
-
if y > 1500: break
|
| 610 |
-
# Title at bottom as small context only, not full article
|
| 611 |
-
title = post.get('title','')
|
| 612 |
-
y2 = 1640
|
| 613 |
-
draw.line((48,y2-22,W-48,y2-22), fill=(70,70,70), width=2)
|
| 614 |
-
for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
|
| 615 |
-
draw.text((48, y2), ln, fill=(220,220,220), font=font_title)
|
| 616 |
-
y2 += 46
|
| 617 |
-
bg.save(out_path, quality=92)
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
def _estimate_audio_duration(path, fallback=4.0):
|
| 621 |
-
try:
|
| 622 |
-
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 623 |
-
return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
|
| 624 |
-
except Exception:
|
| 625 |
-
return fallback
|
| 626 |
-
|
| 627 |
-
|
| 628 |
@app.post('/api/ai/short/{post_id}')
|
| 629 |
async def patched_ai_short(post_id: str, request: Request):
|
| 630 |
-
"""Create 9:16 AI short as multiple timed scenes.
|
| 631 |
-
Each summary paragraph/bullet becomes its own visual scene.
|
| 632 |
-
No subtitle burn-in; only scene text is rendered on frames.
|
| 633 |
-
"""
|
| 634 |
try:
|
| 635 |
body = await request.json()
|
| 636 |
except Exception:
|
|
@@ -645,28 +534,31 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 645 |
if not post:
|
| 646 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 647 |
|
| 648 |
-
segments = _summary_segments_from_post(post, max_segments=7)
|
| 649 |
-
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 650 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 651 |
-
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}
|
| 652 |
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 653 |
if os.path.exists(out_mp4):
|
| 654 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 655 |
post['short_voice'] = voice
|
| 656 |
post['short_emotion'] = emotion
|
| 657 |
post['short_speed'] = speed
|
| 658 |
-
post['short_segments'] = segments
|
| 659 |
-
post['short_subtitles'] = False
|
| 660 |
base._save_ai_wall(posts)
|
| 661 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed
|
| 662 |
if base.gTTS is None:
|
| 663 |
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 664 |
|
| 665 |
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 666 |
os.makedirs(work, exist_ok=True)
|
| 667 |
img = os.path.join(work, 'image.jpg')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
try:
|
| 669 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
|
|
|
|
|
|
|
|
|
| 670 |
edge_voice = {
|
| 671 |
'nam': 'vi-VN-NamMinhNeural',
|
| 672 |
'male': 'vi-VN-NamMinhNeural',
|
|
@@ -674,42 +566,36 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 674 |
'female': 'vi-VN-HoaiMyNeural',
|
| 675 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 676 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
|
| 682 |
-
part = os.path.join(work, f'part_{idx:02d}.mp4')
|
| 683 |
-
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 684 |
-
spoken = _emotion_script(seg, emotion)
|
| 685 |
try:
|
| 686 |
-
|
| 687 |
-
except
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
subprocess.run(['
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 703 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 704 |
post['short_voice'] = voice
|
| 705 |
post['short_emotion'] = emotion
|
| 706 |
post['short_speed'] = speed
|
| 707 |
-
post['
|
| 708 |
-
post['short_subtitles'] = False
|
| 709 |
base._save_ai_wall(posts)
|
| 710 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles':
|
| 711 |
except Exception as e:
|
| 712 |
-
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:
|
| 713 |
|
| 714 |
|
| 715 |
@app.get('/api/ai/short-file/{file_id}')
|
|
|
|
| 518 |
bg.save(out_path, quality=92)
|
| 519 |
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
@app.post('/api/ai/short/{post_id}')
|
| 522 |
async def patched_ai_short(post_id: str, request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
try:
|
| 524 |
body = await request.json()
|
| 525 |
except Exception:
|
|
|
|
| 534 |
if not post:
|
| 535 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 536 |
|
|
|
|
|
|
|
| 537 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 538 |
+
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
|
| 539 |
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 540 |
if os.path.exists(out_mp4):
|
| 541 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 542 |
post['short_voice'] = voice
|
| 543 |
post['short_emotion'] = emotion
|
| 544 |
post['short_speed'] = speed
|
|
|
|
|
|
|
| 545 |
base._save_ai_wall(posts)
|
| 546 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed})
|
| 547 |
if base.gTTS is None:
|
| 548 |
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 549 |
|
| 550 |
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 551 |
os.makedirs(work, exist_ok=True)
|
| 552 |
img = os.path.join(work, 'image.jpg')
|
| 553 |
+
frame = os.path.join(work, 'frame.jpg')
|
| 554 |
+
audio = os.path.join(work, 'voice.mp3')
|
| 555 |
+
audio_fast = os.path.join(work, 'voice_fast.mp3')
|
| 556 |
+
srt = os.path.join(work, 'subtitles.srt')
|
| 557 |
try:
|
| 558 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 559 |
+
_make_short_frame_full(post, img, frame)
|
| 560 |
+
script = _tts_script_smart(post, emotion)
|
| 561 |
+
# True selectable voice via edge-tts if installed. Fallback to gTTS.
|
| 562 |
edge_voice = {
|
| 563 |
'nam': 'vi-VN-NamMinhNeural',
|
| 564 |
'male': 'vi-VN-NamMinhNeural',
|
|
|
|
| 566 |
'female': 'vi-VN-HoaiMyNeural',
|
| 567 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 568 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 569 |
+
try:
|
| 570 |
+
subprocess.run(['python', '-m', 'edge_tts', '--voice', edge_voice, '--text', script, '--write-media', audio], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=160)
|
| 571 |
+
except Exception:
|
| 572 |
+
tld = 'com.vn' if voice in ('nu', 'female', 'mien-nam') else 'com'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
try:
|
| 574 |
+
base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
|
| 575 |
+
except TypeError:
|
| 576 |
+
base.gTTS(script, lang='vi', slow=False).save(audio)
|
| 577 |
+
subprocess.run(['ffmpeg', '-y', '-i', audio, '-filter:a', f'atempo={speed}', '-vn', audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 578 |
+
# Estimate duration for subtitle timing.
|
| 579 |
+
duration = 30.0
|
| 580 |
+
try:
|
| 581 |
+
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 582 |
+
duration = float((pr.stdout or b'30').decode().strip() or 30)
|
| 583 |
+
except Exception:
|
| 584 |
+
pass
|
| 585 |
+
_write_srt(script, srt, duration)
|
| 586 |
+
# Burn subtitles at bottom, with readable style.
|
| 587 |
+
vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=34,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=3,Shadow=1,Alignment=2,MarginV=90'".format(srt.replace("'", "\\'"))
|
| 588 |
+
cmd = ['ffmpeg', '-y', '-loop', '1', '-i', frame, '-i', audio_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-vf', vf, out_mp4]
|
| 589 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
|
|
|
| 590 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 591 |
post['short_voice'] = voice
|
| 592 |
post['short_emotion'] = emotion
|
| 593 |
post['short_speed'] = speed
|
| 594 |
+
post['short_subtitles'] = True
|
|
|
|
| 595 |
base._save_ai_wall(posts)
|
| 596 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True})
|
| 597 |
except Exception as e:
|
| 598 |
+
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:180]}, status_code=500)
|
| 599 |
|
| 600 |
|
| 601 |
@app.get('/api/ai/short-file/{file_id}')
|