Spaces:
Build error
Build error
Patch AI rewrite UI, URL images, and short frame layout
Browse files- ai_runtime.py +197 -48
ai_runtime.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import os, re, subprocess
|
| 2 |
import ai_patch as old
|
| 3 |
from ai_patch import app
|
| 4 |
import ai_ext as base
|
|
@@ -15,14 +15,77 @@ def clean(s):
|
|
| 15 |
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def source_line(sources):
|
| 19 |
names=[]
|
| 20 |
for s in (sources or [])[:5]:
|
| 21 |
-
via=s.get('via') or
|
| 22 |
if via and via not in names:names.append(via)
|
| 23 |
return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet'
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def rich_context(topic, limit=5):
|
| 27 |
try: ctx,sources=base.web_context(topic, limit=limit)
|
| 28 |
except Exception: ctx,sources='',[]
|
|
@@ -36,7 +99,7 @@ def rich_context(topic, limit=5):
|
|
| 36 |
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 37 |
if len(raw)<180:continue
|
| 38 |
title=data.get('title') or s.get('title') or url
|
| 39 |
-
via=data.get('via') or s.get('via') or
|
| 40 |
rich.append(f"### {title} ({via})\n{raw[:2600]}")
|
| 41 |
rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via})
|
| 42 |
if len(rich)>=limit:break
|
|
@@ -47,61 +110,85 @@ def rich_context(topic, limit=5):
|
|
| 47 |
|
| 48 |
def postprocess(text):
|
| 49 |
if hasattr(old,'_postprocess_ai_text'):
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
# Remove old routes we must override.
|
| 55 |
-
_PATCH={('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 56 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 57 |
|
| 58 |
|
| 59 |
-
@app.post('/api/
|
| 60 |
-
async def
|
| 61 |
-
body=await request.json();
|
| 62 |
-
if not
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
Yêu cầu:
|
| 70 |
-
-
|
| 71 |
-
-
|
| 72 |
-
- Không
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
Nguồn
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
text=postprocess(text)
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 84 |
return JSONResponse({'post':post})
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def split_segments(post,max_segments=8):
|
| 88 |
text=clean(post.get('text') or post.get('title') or '')
|
| 89 |
text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
|
| 90 |
lines=[]
|
| 91 |
for ln in text.splitlines():
|
| 92 |
-
ln=
|
| 93 |
if len(ln)>=18:lines.append(ln)
|
| 94 |
if len(lines)<2:
|
| 95 |
-
lines=[
|
| 96 |
-
# keep each segment moderate
|
| 97 |
segs=[];cur=''
|
| 98 |
for ln in lines:
|
|
|
|
|
|
|
| 99 |
if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
|
| 100 |
else:
|
| 101 |
-
if cur:segs.append(cur)
|
| 102 |
cur=ln
|
| 103 |
-
if cur:segs.append(cur)
|
| 104 |
-
return segs[:max_segments] or [post.get('title','VNEWS')]
|
| 105 |
|
| 106 |
|
| 107 |
def wrap_text(draw,text,font,maxw,max_lines):
|
|
@@ -119,12 +206,25 @@ def wrap_text(draw,text,font,maxw,max_lines):
|
|
| 119 |
return lines
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def make_frame(post,seg,idx,total,img_path,out_path):
|
| 123 |
if Image is None:raise RuntimeError('Pillow not ready')
|
| 124 |
W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
|
|
|
|
| 125 |
try:
|
| 126 |
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
|
| 127 |
-
target=(
|
| 128 |
if ratio>tr:nh=target[1];nw=int(nh*ratio)
|
| 129 |
else:nw=target[0];nh=int(nw/ratio)
|
| 130 |
im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
|
|
@@ -133,19 +233,47 @@ def make_frame(post,seg,idx,total,img_path,out_path):
|
|
| 133 |
draw=ImageDraw.Draw(bg)
|
| 134 |
try:
|
| 135 |
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
bg.save(out_path,quality=92)
|
| 145 |
|
| 146 |
|
| 147 |
def make_tts(text,voice,out_path):
|
| 148 |
v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
|
|
|
|
| 149 |
try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
|
| 150 |
except Exception:
|
| 151 |
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
|
@@ -161,7 +289,7 @@ async def short_segments(post_id:str,request:Request):
|
|
| 161 |
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 162 |
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 163 |
segs=split_segments(post,8)
|
| 164 |
-
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}
|
| 165 |
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 166 |
if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 167 |
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
|
@@ -170,6 +298,7 @@ async def short_segments(post_id:str,request:Request):
|
|
| 170 |
try:
|
| 171 |
for i,seg in enumerate(segs):
|
| 172 |
frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
|
|
|
|
| 173 |
make_frame(post,seg,i,len(segs),img,frame)
|
| 174 |
prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
|
| 175 |
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
|
@@ -193,16 +322,36 @@ def short_file(file_id:str):
|
|
| 193 |
return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
|
| 194 |
|
| 195 |
|
| 196 |
-
# Rebuild / with old UI injection plus
|
| 197 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 198 |
@app.get('/')
|
| 199 |
async def index_runtime():
|
| 200 |
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 201 |
inject=getattr(old,'PATCH_INJECT','')+r'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
<script>
|
| 203 |
(function(){
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
})();
|
| 206 |
</script>
|
| 207 |
'''
|
| 208 |
-
return HTMLResponse(html.replace('</body>',inject+'\n</body>'))
|
|
|
|
| 1 |
+
import os, re, subprocess, json, time, hashlib
|
| 2 |
import ai_patch as old
|
| 3 |
from ai_patch import app
|
| 4 |
import ai_ext as base
|
|
|
|
| 15 |
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 16 |
|
| 17 |
|
| 18 |
+
def _domain(url):
|
| 19 |
+
try:
|
| 20 |
+
from urllib.parse import urlparse
|
| 21 |
+
return urlparse(url or '').netloc.replace('www.','')
|
| 22 |
+
except Exception:
|
| 23 |
+
return ''
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _strip_bullet_prefix(s):
|
| 27 |
+
# remove bullets, numbered prefixes, leading dots commonly produced by AI summaries
|
| 28 |
+
return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
def source_line(sources):
|
| 32 |
names=[]
|
| 33 |
for s in (sources or [])[:5]:
|
| 34 |
+
via=s.get('via') or _domain(s.get('url','')) or s.get('title','')
|
| 35 |
if via and via not in names:names.append(via)
|
| 36 |
return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet'
|
| 37 |
|
| 38 |
|
| 39 |
+
def _source_badge(post):
|
| 40 |
+
sources=post.get('sources') or []
|
| 41 |
+
for s in sources:
|
| 42 |
+
via=s.get('via') or _domain(s.get('url',''))
|
| 43 |
+
if via:return via
|
| 44 |
+
return _domain(post.get('url','')) or post.get('source') or 'VNEWS'
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _collect_all_images(data):
|
| 48 |
+
imgs=[]
|
| 49 |
+
def add(u):
|
| 50 |
+
u=(u or '').strip()
|
| 51 |
+
if not u or u.startswith('data:') or 'base64' in u:return
|
| 52 |
+
if u.startswith('//'):u='https:'+u
|
| 53 |
+
if u not in imgs:imgs.append(u)
|
| 54 |
+
add(data.get('image') or data.get('og_image') or data.get('img'))
|
| 55 |
+
for u in data.get('images') or []:add(u)
|
| 56 |
+
for b in data.get('body') or []:
|
| 57 |
+
if isinstance(b,dict) and b.get('type')=='img':add(b.get('src'))
|
| 58 |
+
return imgs[:20]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _scrape_url_with_images(url):
|
| 62 |
+
data=base.scrape_any_url(url)
|
| 63 |
+
# extra pass: collect every useful image from original HTML, because some readers only return one image
|
| 64 |
+
try:
|
| 65 |
+
import requests
|
| 66 |
+
from bs4 import BeautifulSoup
|
| 67 |
+
r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8'
|
| 68 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 69 |
+
extra=[]
|
| 70 |
+
for im in soup.find_all('img'):
|
| 71 |
+
src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
|
| 72 |
+
if src.startswith('//'):src='https:'+src
|
| 73 |
+
if src and 'base64' not in src and src not in extra:
|
| 74 |
+
# skip tiny icons/logos as much as possible
|
| 75 |
+
low=src.lower()
|
| 76 |
+
if any(x in low for x in ['logo','icon','avatar','sprite']):
|
| 77 |
+
continue
|
| 78 |
+
extra.append(src)
|
| 79 |
+
if len(extra)>=20:break
|
| 80 |
+
data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)]
|
| 81 |
+
except Exception:
|
| 82 |
+
data['images']=_collect_all_images(data)
|
| 83 |
+
data['images']=_collect_all_images(data)
|
| 84 |
+
if data['images'] and not data.get('image'):
|
| 85 |
+
data['image']=data['images'][0]
|
| 86 |
+
return data
|
| 87 |
+
|
| 88 |
+
|
| 89 |
def rich_context(topic, limit=5):
|
| 90 |
try: ctx,sources=base.web_context(topic, limit=limit)
|
| 91 |
except Exception: ctx,sources='',[]
|
|
|
|
| 99 |
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 100 |
if len(raw)<180:continue
|
| 101 |
title=data.get('title') or s.get('title') or url
|
| 102 |
+
via=data.get('via') or s.get('via') or _domain(url)
|
| 103 |
rich.append(f"### {title} ({via})\n{raw[:2600]}")
|
| 104 |
rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via})
|
| 105 |
if len(rich)>=limit:break
|
|
|
|
| 110 |
|
| 111 |
def postprocess(text):
|
| 112 |
if hasattr(old,'_postprocess_ai_text'):
|
| 113 |
+
out=old._postprocess_ai_text(text, max_units=7)
|
| 114 |
+
else:
|
| 115 |
+
out=clean(text)
|
| 116 |
+
# keep wall text readable, but ensure short generation later won't show bullets
|
| 117 |
+
return out
|
| 118 |
|
| 119 |
|
| 120 |
# Remove old routes we must override.
|
| 121 |
+
_PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 122 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 123 |
|
| 124 |
|
| 125 |
+
@app.post('/api/url_wall')
|
| 126 |
+
async def url_wall_only(request:Request):
|
| 127 |
+
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 128 |
+
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 129 |
+
try:data=_scrape_url_with_images(url)
|
| 130 |
+
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 131 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 132 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 133 |
+
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 134 |
+
|
| 135 |
+
Yêu cầu bắt buộc:
|
| 136 |
+
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 137 |
+
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 138 |
+
- Không lặp lại ý và không thêm chi tiết ngoài nguồn.
|
| 139 |
+
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 140 |
+
- Tránh dùng dấu đầu dòng nếu không thật cần thiết.
|
| 141 |
+
|
| 142 |
+
Tiêu đề gốc: {data.get('title','')}
|
| 143 |
+
Nguồn: {data.get('via','') or _domain(url)}
|
| 144 |
+
Nội dung gốc:
|
| 145 |
+
{raw[:16000]}"""
|
| 146 |
+
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 147 |
+
if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
|
| 148 |
text=postprocess(text)
|
| 149 |
+
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}]
|
| 150 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
|
| 151 |
+
images=_collect_all_images(data)
|
| 152 |
+
post=base.make_post(data.get('title') or 'Bài viết',text,images[0] if images else (data.get('image') or ''),url,'url',sources=src)
|
| 153 |
+
post['images']=images
|
| 154 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 155 |
return JSONResponse({'post':post})
|
| 156 |
|
| 157 |
|
| 158 |
+
@app.post('/api/rewrite_share')
|
| 159 |
+
async def rewrite_share_url_only(request:Request):
|
| 160 |
+
return await url_wall_only(request)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.post('/api/ai/url')
|
| 164 |
+
async def ai_url_compat(request:Request):
|
| 165 |
+
return await url_wall_only(request)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.post('/api/topic_post')
|
| 169 |
+
async def topic_disabled(request:Request):
|
| 170 |
+
return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def split_segments(post,max_segments=8):
|
| 174 |
text=clean(post.get('text') or post.get('title') or '')
|
| 175 |
text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
|
| 176 |
lines=[]
|
| 177 |
for ln in text.splitlines():
|
| 178 |
+
ln=_strip_bullet_prefix(ln)
|
| 179 |
if len(ln)>=18:lines.append(ln)
|
| 180 |
if len(lines)<2:
|
| 181 |
+
lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
|
|
|
|
| 182 |
segs=[];cur=''
|
| 183 |
for ln in lines:
|
| 184 |
+
ln=_strip_bullet_prefix(ln)
|
| 185 |
+
if not ln:continue
|
| 186 |
if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
|
| 187 |
else:
|
| 188 |
+
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 189 |
cur=ln
|
| 190 |
+
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 191 |
+
return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
|
| 192 |
|
| 193 |
|
| 194 |
def wrap_text(draw,text,font,maxw,max_lines):
|
|
|
|
| 206 |
return lines
|
| 207 |
|
| 208 |
|
| 209 |
+
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 210 |
+
for ln in lines:
|
| 211 |
+
try:
|
| 212 |
+
box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 213 |
+
except Exception:
|
| 214 |
+
tw=len(ln)*24
|
| 215 |
+
x=max(30,(W-tw)//2)
|
| 216 |
+
draw.text((x,y),ln,fill=fill,font=font)
|
| 217 |
+
y+=line_h
|
| 218 |
+
return y
|
| 219 |
+
|
| 220 |
+
|
| 221 |
def make_frame(post,seg,idx,total,img_path,out_path):
|
| 222 |
if Image is None:raise RuntimeError('Pillow not ready')
|
| 223 |
W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
|
| 224 |
+
hero_h=760
|
| 225 |
try:
|
| 226 |
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
|
| 227 |
+
target=(W,hero_h);tr=target[0]/target[1]
|
| 228 |
if ratio>tr:nh=target[1];nw=int(nh*ratio)
|
| 229 |
else:nw=target[0];nh=int(nw/ratio)
|
| 230 |
im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
|
|
|
|
| 233 |
draw=ImageDraw.Draw(bg)
|
| 234 |
try:
|
| 235 |
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 236 |
+
ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
|
| 237 |
+
fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
|
| 238 |
+
fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
|
| 239 |
+
except Exception:fb=ft=fs=fsmall=None
|
| 240 |
+
# source badge on top image corner
|
| 241 |
+
badge='Nguồn: '+_source_badge(post)
|
| 242 |
+
try:
|
| 243 |
+
b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
|
| 244 |
+
except Exception:
|
| 245 |
+
bw=len(badge)*16;bh=34
|
| 246 |
+
bx=W-bw-42;by=24
|
| 247 |
+
draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
|
| 248 |
+
draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
|
| 249 |
+
# bottom text area
|
| 250 |
+
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 251 |
+
# progress bars centered
|
| 252 |
+
total_w=total*38-14;start=(W-total_w)//2
|
| 253 |
+
for i in range(total):
|
| 254 |
+
fill=(92,184,122) if i==idx else (70,70,70)
|
| 255 |
+
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 256 |
+
brand='VNEWS AI SHORT'
|
| 257 |
+
try:
|
| 258 |
+
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 259 |
+
except Exception:tx=360
|
| 260 |
+
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 261 |
+
clean_seg=_strip_bullet_prefix(seg)
|
| 262 |
+
lines=wrap_text(draw,clean_seg,fb,W-120,8)
|
| 263 |
+
block_h=len(lines)*74
|
| 264 |
+
y=max(980, 1250-block_h//2)
|
| 265 |
+
_draw_center(draw,lines,fb,y,(255,255,255),W,74)
|
| 266 |
+
# small title centered near bottom
|
| 267 |
+
title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
|
| 268 |
+
y2=1640
|
| 269 |
+
draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
|
| 270 |
+
_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 271 |
bg.save(out_path,quality=92)
|
| 272 |
|
| 273 |
|
| 274 |
def make_tts(text,voice,out_path):
|
| 275 |
v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
|
| 276 |
+
text=_strip_bullet_prefix(text)
|
| 277 |
try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
|
| 278 |
except Exception:
|
| 279 |
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
|
|
|
| 289 |
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 290 |
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 291 |
segs=split_segments(post,8)
|
| 292 |
+
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
|
| 293 |
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 294 |
if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 295 |
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
|
|
|
| 298 |
try:
|
| 299 |
for i,seg in enumerate(segs):
|
| 300 |
frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
|
| 301 |
+
seg=_strip_bullet_prefix(seg)
|
| 302 |
make_frame(post,seg,i,len(segs),img,frame)
|
| 303 |
prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
|
| 304 |
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
|
|
|
| 322 |
return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
|
| 323 |
|
| 324 |
|
| 325 |
+
# Rebuild / with old UI injection plus final UI overrides.
|
| 326 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 327 |
@app.get('/')
|
| 328 |
async def index_runtime():
|
| 329 |
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 330 |
inject=getattr(old,'PATCH_INJECT','')+r'''
|
| 331 |
+
<style>
|
| 332 |
+
/* Hide old topic UI, keep URL input only */
|
| 333 |
+
#ai-topic-input{display:none!important}
|
| 334 |
+
#ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
|
| 335 |
+
.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
|
| 336 |
+
.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
|
| 337 |
+
</style>
|
| 338 |
<script>
|
| 339 |
(function(){
|
| 340 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 341 |
+
function hideTopicControls(){
|
| 342 |
+
document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
|
| 343 |
+
document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
|
| 344 |
+
let url=document.getElementById('ai-url-input'); if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
|
| 345 |
+
}
|
| 346 |
+
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
|
| 347 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 348 |
+
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 349 |
+
function patchReaders(){
|
| 350 |
+
let oldRead=window.aiReadWallPatched||window.aiReadWall;
|
| 351 |
+
window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(i);if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
|
| 352 |
+
}
|
| 353 |
+
setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
|
| 354 |
})();
|
| 355 |
</script>
|
| 356 |
'''
|
| 357 |
+
return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
|