bep40 commited on
Commit
a27cf19
·
verified ·
1 Parent(s): 579829c

Patch AI rewrite UI, URL images, and short frame layout

Browse files
Files changed (1) hide show
  1. 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 base._domain(s.get('url','')) or s.get('title','')
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 base._domain(url)
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
- return old._postprocess_ai_text(text, max_units=7)
51
- return clean(text)
 
 
 
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/topic_post')
60
- async def topic_post_aggregate(request:Request):
61
- body=await request.json();topic=base._clean_text(body.get('topic',''))
62
- if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
63
- ctx,sources=rich_context(topic,5)
64
- image=base.pollinations_image_url(topic)
65
- prompt=f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
66
-
67
- Chủ đề: {topic}
68
-
69
- Yêu cầu:
70
- - Đọc nội dung các nguồn bên dưới tạo 1 bản tóm tắt chung duy nhất.
71
- - Không tạo mỗi tiêu đề thành một bài riêng.
72
- - Không chỉ liệt tiêu đề; nếu nội dung bài nguồn thì phải dựa trên nội dung đó.
73
- - Không lặp ý.
74
- - Tối đa 6 gạch đầu dòng.
75
- - Cuối cùng ghi nguồn tham khảo.
76
-
77
- Nguồn/bối cảnh internet:
78
- {ctx[:16000]}"""
79
- text=await base.qwen_generate(prompt,image_url=image,max_tokens=1000)
 
 
80
  text=postprocess(text)
81
- if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(sources)
82
- post=base.make_post('Tổng hợp: '+topic,text,image,'','topic',sources=sources[:5])
 
 
 
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=clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
93
  if len(ln)>=18:lines.append(ln)
94
  if len(lines)<2:
95
- lines=[clean(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(clean(s))>=25]
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=(1080,720);tr=target[0]/target[1]
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
- fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',32)
137
- except Exception:fb=fs=None
138
- draw.rectangle((0,680,W,H),fill=(12,12,12))
139
- draw.text((48,750),f'VNEWS AI SHORT · Đoạn {idx+1}/{total}',fill=(92,184,122),font=fs)
140
- y=850
141
- for ln in wrap_text(draw,seg,fb,W-96,10):
142
- draw.text((48,y),ln,fill=(255,255,255),font=fb);y+=74
143
- if y>1600:break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")}_segments_nosub'
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 override messages for segmented no-sub shorts.
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
- window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp nội dung các nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ý 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)