Spaces:
Running
Running
Upload ai_patch.py
Browse files- ai_patch.py +95 -146
ai_patch.py
CHANGED
|
@@ -286,7 +286,6 @@ _PATCHED_PATHS = {
|
|
| 286 |
('/api/url_wall', 'POST'),
|
| 287 |
('/api/rewrite_share', 'POST'),
|
| 288 |
('/api/ai/short/{post_id}', 'POST'),
|
| 289 |
-
('/api/auto_poster/run', 'POST'),
|
| 290 |
}
|
| 291 |
app.router.routes = [
|
| 292 |
r for r in app.router.routes
|
|
@@ -328,7 +327,7 @@ Nội dung bài:
|
|
| 328 |
if 'Nguồn tham khảo:' not in text:
|
| 329 |
text += "\n\n" + _source_line(src)
|
| 330 |
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 331 |
-
|
| 332 |
# Generate slides for this post so they persist after page reload
|
| 333 |
try:
|
| 334 |
page_data = _scrape_article_images(art.get('url', ''))
|
|
@@ -345,7 +344,7 @@ Nội dung bài:
|
|
| 345 |
post['slides'] = slides
|
| 346 |
except Exception:
|
| 347 |
pass
|
| 348 |
-
|
| 349 |
new_posts.append(post)
|
| 350 |
posts = new_posts + posts
|
| 351 |
base._save_ai_wall(posts)
|
|
@@ -372,7 +371,7 @@ async def compat_url_wall(request: Request):
|
|
| 372 |
if 'Nguồn tham khảo:' not in text:
|
| 373 |
text += "\n\n" + _source_line(src)
|
| 374 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 375 |
-
|
| 376 |
# Generate slides so they persist after page reload
|
| 377 |
slides = []
|
| 378 |
try:
|
|
@@ -389,7 +388,7 @@ async def compat_url_wall(request: Request):
|
|
| 389 |
except Exception:
|
| 390 |
pass
|
| 391 |
post['slides'] = slides
|
| 392 |
-
|
| 393 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 394 |
return JSONResponse({'post': post, 'slides': slides})
|
| 395 |
|
|
@@ -437,7 +436,7 @@ def _extract_key_points_for_slides(paragraphs, max_points=12):
|
|
| 437 |
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 438 |
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 439 |
sentences = [s.strip() for s in sentences if s.strip()]
|
| 440 |
-
|
| 441 |
for sentence in sentences:
|
| 442 |
if len(points) >= max_points:
|
| 443 |
break
|
|
@@ -547,7 +546,7 @@ async def compat_rewrite_share(request: Request):
|
|
| 547 |
|
| 548 |
def _emotion_script(text, emotion):
|
| 549 |
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 550 |
-
|
| 551 |
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 552 |
The emotion is still used for voice selection but content is read cleanly.
|
| 553 |
"""
|
|
@@ -713,7 +712,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 713 |
if ratio>target:
|
| 714 |
nh=H; nw=int(nh*ratio)
|
| 715 |
else:
|
| 716 |
-
nw=W; nh=int(nw
|
| 717 |
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 718 |
cover=cover.crop((left,top,left+W,top+H))
|
| 719 |
bg.paste(cover,(0,0))
|
|
@@ -767,77 +766,21 @@ def _estimate_audio_duration(path, fallback=15.0):
|
|
| 767 |
return fallback
|
| 768 |
|
| 769 |
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
'
|
| 777 |
-
'
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 781 |
-
# English - Multilingual
|
| 782 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 783 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 784 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 785 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 786 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 787 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 788 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 789 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 790 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 791 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 792 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 793 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 794 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 795 |
-
# French - Multilingual
|
| 796 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 797 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 798 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 799 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 800 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 801 |
-
# German - Multilingual
|
| 802 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 803 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 804 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 805 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 806 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 807 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 808 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 809 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 810 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 811 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 812 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 813 |
-
# Italian - Multilingual
|
| 814 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 815 |
-
# Spanish (keep for backward compat)
|
| 816 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 817 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 818 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 819 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
# Japanese (keep for backward compat)
|
| 821 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 823 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
# Chinese (keep for backward compat)
|
| 825 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 826 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 827 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 828 |
-
}
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
def _generate_short_for_post(post_id: str, voice: str = 'nu', emotion: str = 'neutral', speed: float = 1.0):
|
| 832 |
-
"""Hàm sinh short dùng chung (route /api/ai/short và auto_poster đều gọi). Trả về dict kết quả."""
|
| 833 |
-
voice = str(voice).strip().lower()
|
| 834 |
-
emotion = str(emotion).strip().lower()
|
| 835 |
-
speed = max(0.85, min(1.35, float(speed or 1.0)))
|
| 836 |
|
| 837 |
posts = base._load_ai_wall()
|
| 838 |
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 839 |
if not post:
|
| 840 |
-
return {'error': 'post not found'}
|
| 841 |
|
| 842 |
segments = _summary_segments_from_post(post, max_segments=25)
|
| 843 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
|
@@ -852,41 +795,99 @@ def _generate_short_for_post(post_id: str, voice: str = 'nu', emotion: str = 'ne
|
|
| 852 |
post['short_segments'] = segments
|
| 853 |
post['short_subtitles'] = False
|
| 854 |
base._save_ai_wall(posts)
|
| 855 |
-
return {'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments
|
| 856 |
if base.gTTS is None:
|
| 857 |
-
return {'error': 'gTTS chưa sẵn sàng'}
|
| 858 |
|
| 859 |
-
edge_voice = _EDGE_VOICE_MAP.get(voice, 'vi-VN-HoaiMyNeural')
|
| 860 |
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 861 |
os.makedirs(work, exist_ok=True)
|
| 862 |
img = os.path.join(work, 'image.jpg')
|
| 863 |
try:
|
| 864 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 865 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
for idx, seg in enumerate(segments):
|
| 867 |
-
frame
|
| 868 |
-
aud
|
| 869 |
-
aud_fast
|
| 870 |
-
part
|
| 871 |
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 872 |
-
spoken
|
| 873 |
try:
|
| 874 |
-
subprocess.run(['python',
|
| 875 |
except Exception:
|
| 876 |
-
tld
|
| 877 |
try:
|
| 878 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 879 |
except TypeError:
|
| 880 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 881 |
-
subprocess.run(['ffmpeg',
|
| 882 |
-
dur
|
| 883 |
-
subprocess.run(['ffmpeg',
|
| 884 |
part_files.append(part)
|
| 885 |
-
concat
|
| 886 |
-
with open(concat,
|
| 887 |
for p in part_files:
|
| 888 |
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 889 |
-
subprocess.run(['ffmpeg',
|
| 890 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 891 |
post['short_voice'] = voice
|
| 892 |
post['short_emotion'] = emotion
|
|
@@ -894,25 +895,9 @@ def _generate_short_for_post(post_id: str, voice: str = 'nu', emotion: str = 'ne
|
|
| 894 |
post['short_segments'] = segments
|
| 895 |
post['short_subtitles'] = False
|
| 896 |
base._save_ai_wall(posts)
|
| 897 |
-
return {'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments
|
| 898 |
except Exception as e:
|
| 899 |
-
return {'error': 'Không tạo được shorts: ' + str(e)[:220]}
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
@app.post('/api/ai/short/{post_id}')
|
| 903 |
-
async def patched_ai_short(post_id: str, request: Request):
|
| 904 |
-
try:
|
| 905 |
-
body = await request.json()
|
| 906 |
-
except Exception:
|
| 907 |
-
body = {}
|
| 908 |
-
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 909 |
-
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 910 |
-
speed = float(body.get('speed', 1.0) or 1.0)
|
| 911 |
-
result = _generate_short_for_post(post_id, voice=voice, emotion=emotion, speed=speed)
|
| 912 |
-
if 'error' in result:
|
| 913 |
-
code = 404 if result['error'] == 'post not found' else (503 if 'gTTS' in result['error'] else 500)
|
| 914 |
-
return JSONResponse({'error': result['error']}, status_code=code)
|
| 915 |
-
return JSONResponse(result)
|
| 916 |
|
| 917 |
|
| 918 |
@app.get('/api/ai/short-file/{file_id}')
|
|
@@ -929,40 +914,4 @@ def api_ai_shorts():
|
|
| 929 |
return JSONResponse({'posts': posts[:80]})
|
| 930 |
|
| 931 |
|
| 932 |
-
# ============================================================
|
| 933 |
-
# AUTO POSTER — đăng tự động rewrite + short lúc 7h, 13h, 19h
|
| 934 |
-
# ============================================================
|
| 935 |
-
try:
|
| 936 |
-
import auto_poster
|
| 937 |
-
_AUTO_POSTER_OK = True
|
| 938 |
-
except Exception as _ap_err:
|
| 939 |
-
_AUTO_POSTER_OK = False
|
| 940 |
-
print("[auto_poster] import failed:", _ap_err)
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
@app.post('/api/auto_poster/run')
|
| 944 |
-
async def api_auto_poster_run(request: Request):
|
| 945 |
-
if not _AUTO_POSTER_OK:
|
| 946 |
-
return JSONResponse({'error': 'auto_poster chưa sẵn sàng'}, status_code=503)
|
| 947 |
-
try:
|
| 948 |
-
body = await request.json()
|
| 949 |
-
except Exception:
|
| 950 |
-
body = {}
|
| 951 |
-
topic = (body or {}).get('topic')
|
| 952 |
-
with_short = bool((body or {}).get('with_short', True))
|
| 953 |
-
result = auto_poster.run_once(topic=topic, with_short=with_short)
|
| 954 |
-
return JSONResponse(result)
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
@app.get('/api/auto_poster/status')
|
| 958 |
-
def api_auto_poster_status():
|
| 959 |
-
if not _AUTO_POSTER_OK:
|
| 960 |
-
return JSONResponse({'running': False, 'error': 'auto_poster import failed'}, status_code=503)
|
| 961 |
-
return JSONResponse(auto_poster.status())
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
if _AUTO_POSTER_OK:
|
| 965 |
-
auto_poster.start()
|
| 966 |
-
|
| 967 |
-
|
| 968 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
| 286 |
('/api/url_wall', 'POST'),
|
| 287 |
('/api/rewrite_share', 'POST'),
|
| 288 |
('/api/ai/short/{post_id}', 'POST'),
|
|
|
|
| 289 |
}
|
| 290 |
app.router.routes = [
|
| 291 |
r for r in app.router.routes
|
|
|
|
| 327 |
if 'Nguồn tham khảo:' not in text:
|
| 328 |
text += "\n\n" + _source_line(src)
|
| 329 |
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 330 |
+
|
| 331 |
# Generate slides for this post so they persist after page reload
|
| 332 |
try:
|
| 333 |
page_data = _scrape_article_images(art.get('url', ''))
|
|
|
|
| 344 |
post['slides'] = slides
|
| 345 |
except Exception:
|
| 346 |
pass
|
| 347 |
+
|
| 348 |
new_posts.append(post)
|
| 349 |
posts = new_posts + posts
|
| 350 |
base._save_ai_wall(posts)
|
|
|
|
| 371 |
if 'Nguồn tham khảo:' not in text:
|
| 372 |
text += "\n\n" + _source_line(src)
|
| 373 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
+
|
| 375 |
# Generate slides so they persist after page reload
|
| 376 |
slides = []
|
| 377 |
try:
|
|
|
|
| 388 |
except Exception:
|
| 389 |
pass
|
| 390 |
post['slides'] = slides
|
| 391 |
+
|
| 392 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 393 |
return JSONResponse({'post': post, 'slides': slides})
|
| 394 |
|
|
|
|
| 436 |
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 437 |
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 438 |
sentences = [s.strip() for s in sentences if s.strip()]
|
| 439 |
+
|
| 440 |
for sentence in sentences:
|
| 441 |
if len(points) >= max_points:
|
| 442 |
break
|
|
|
|
| 546 |
|
| 547 |
def _emotion_script(text, emotion):
|
| 548 |
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 549 |
+
|
| 550 |
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 551 |
The emotion is still used for voice selection but content is read cleanly.
|
| 552 |
"""
|
|
|
|
| 712 |
if ratio>target:
|
| 713 |
nh=H; nw=int(nh*ratio)
|
| 714 |
else:
|
| 715 |
+
nw=W; nh=int(nw/ratio)
|
| 716 |
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 717 |
cover=cover.crop((left,top,left+W,top+H))
|
| 718 |
bg.paste(cover,(0,0))
|
|
|
|
| 766 |
return fallback
|
| 767 |
|
| 768 |
|
| 769 |
+
@app.post('/api/ai/short/{post_id}')
|
| 770 |
+
async def patched_ai_short(post_id: str, request: Request):
|
| 771 |
+
try:
|
| 772 |
+
body = await request.json()
|
| 773 |
+
except Exception:
|
| 774 |
+
body = {}
|
| 775 |
+
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
+
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
+
speed = float(body.get('speed', 1.0) or 1.0)
|
| 778 |
+
speed = max(0.85, min(1.35, speed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
|
| 780 |
posts = base._load_ai_wall()
|
| 781 |
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 782 |
if not post:
|
| 783 |
+
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
|
| 785 |
segments = _summary_segments_from_post(post, max_segments=25)
|
| 786 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
|
|
|
| 795 |
post['short_segments'] = segments
|
| 796 |
post['short_subtitles'] = False
|
| 797 |
base._save_ai_wall(posts)
|
| 798 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 799 |
if base.gTTS is None:
|
| 800 |
+
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 801 |
|
|
|
|
| 802 |
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 803 |
os.makedirs(work, exist_ok=True)
|
| 804 |
img = os.path.join(work, 'image.jpg')
|
| 805 |
try:
|
| 806 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
+
edge_voice = {
|
| 808 |
+
# Vietnamese
|
| 809 |
+
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
+
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
+
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
+
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
+
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
+
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
+
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
+
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
+
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
+
# English - Multilingual
|
| 819 |
+
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
+
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
+
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
+
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
+
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
+
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
+
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
+
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
+
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
+
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
+
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
+
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
+
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
+
# French - Multilingual
|
| 833 |
+
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
+
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
+
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
+
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
+
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
+
# German - Multilingual
|
| 839 |
+
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
+
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
+
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
+
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
+
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
+
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
+
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
+
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
+
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
+
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
+
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
+
# Italian - Multilingual
|
| 851 |
+
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
+
# Spanish (keep for backward compat)
|
| 853 |
+
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
+
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
+
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
+
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
+
# Japanese (keep for backward compat)
|
| 858 |
+
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
+
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
+
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
+
# Chinese (keep for backward compat)
|
| 862 |
+
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
+
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
+
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
+
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
+
part_files=[]
|
| 867 |
for idx, seg in enumerate(segments):
|
| 868 |
+
frame=os.path.join(work,f'frame_{idx:02d}.jpg')
|
| 869 |
+
aud=os.path.join(work,f'voice_{idx:02d}.mp3')
|
| 870 |
+
aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
|
| 871 |
+
part=os.path.join(work,f'part_{idx:02d}.mp4')
|
| 872 |
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 873 |
+
spoken=_emotion_script(seg, emotion)
|
| 874 |
try:
|
| 875 |
+
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
except Exception:
|
| 877 |
+
tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
|
| 878 |
try:
|
| 879 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
except TypeError:
|
| 881 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
+
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
+
dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
|
| 884 |
+
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 885 |
part_files.append(part)
|
| 886 |
+
concat=os.path.join(work,'concat.txt')
|
| 887 |
+
with open(concat,'w',encoding='utf-8') as f:
|
| 888 |
for p in part_files:
|
| 889 |
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 890 |
+
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 891 |
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 892 |
post['short_voice'] = voice
|
| 893 |
post['short_emotion'] = emotion
|
|
|
|
| 895 |
post['short_segments'] = segments
|
| 896 |
post['short_subtitles'] = False
|
| 897 |
base._save_ai_wall(posts)
|
| 898 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 899 |
except Exception as e:
|
| 900 |
+
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
|
| 902 |
|
| 903 |
@app.get('/api/ai/short-file/{file_id}')
|
|
|
|
| 914 |
return JSONResponse({'posts': posts[:80]})
|
| 915 |
|
| 916 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|