"""Final patch v6: FIXED short video generation — Vietnamese fonts, robust text splitting, no silent errors.""" import re, threading, time, json, os, asyncio, hashlib, subprocess, requests, sys, logging import ai_runtime_final6 as f6 from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query from fastapi.responses import FileResponse import html as html_lib from urllib.parse import urlparse from datetime import datetime, timezone, timedelta _log = logging.getLogger("patch_fast") _log.setLevel(logging.INFO) if not _log.handlers: _log.addHandler(logging.StreamHandler(sys.stderr)) def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() def _domain(u): try:return urlparse(u or '').netloc.replace('www.','') except:return '' DATA_DIR="/data" if os.path.isdir('/data') else "/app/data" os.makedirs(DATA_DIR,exist_ok=True) SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json') TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data') SHORTS_DIR = os.path.join(DATA_DIR, 'ai_shorts') os.makedirs(SHORTS_DIR, exist_ok=True) def _lj(p,d): try: if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8')) except:pass return d def _sj(p,d): try:os.makedirs(os.path.dirname(p),exist_ok=True);open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p) except:pass def _cleanup(): n=int(time.time());ps=f5.base._load_ai_wall();f=[p for p in ps if n-int(p.get('ts') or 0) min_len: segmented.append(line_clean) elif len(line) > min_len: segmented.append(line) # Strategy 2: If no bullet points found, split by sentences if len(segmented) < 2: # Split by sentences (Vietnamese period, exclamation, question) sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text) for s in sents: s = clean(s) if len(s) > min_len: segmented.append(s) # Strategy 3: If still no good segments, split by character chunks if not segmented: words = text.split() chunk = [] char_count = 0 for w in words: chunk.append(w) char_count += len(w) + 1 if char_count > 150: segmented.append(' '.join(chunk)) chunk = [] char_count = 0 if chunk: segmented.append(' '.join(chunk)) # Strategy 4: Last resort — take whole text if not segmented: segmented = [text[:300]] return segmented[:max_segments] # ====================================================================== # FRAME CREATION — with Vietnamese font support # ====================================================================== def _make_frame(post, seg, idx, total, img_path, downloaded, frame_path): """Create a single frame with Vietnamese text support - matching original layout.""" from PIL import Image, ImageDraw _find_vn_fonts() W, H = 1080, 1920 hero_h = 760 bg = Image.new('RGB', (W, H), (12, 12, 12)) draw = ImageDraw.Draw(bg) # Background image (hero) if downloaded: try: im = Image.open(img_path).convert('RGB') ratio = im.width / max(1, im.height) tr = W / hero_h if ratio > tr: nh = hero_h; nw = int(nh * ratio) else: nw = W; nh = int(nw / ratio) im = im.resize((nw, nh)) left = (nw - W) // 2 top = (nh - hero_h) // 2 bg.paste(im.crop((left, top, left + W, top + hero_h)), (0, 0)) except Exception as e: _log.warning(f"paste img: {e}") try: fb = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 58) except: fb = None # Source badge on top image corner badge = 'Nguồn: ' + _source_badge_url_first(post) try: b = draw.textbbox((0, 0), badge, font=fb); bw = b[2] - b[0]; bh = b[3] - b[1] except: bw = len(badge) * 16; bh = 34 bx = W - bw - 42; by = 24 draw.rounded_rectangle((bx - 16, by - 8, W - 24, by + bh + 14), radius=18, fill=(0, 0, 0, 170)) draw.text((bx, by), badge, fill=(255, 255, 255), font=fb) # Bottom text area draw.rectangle((0, hero_h - 20, W, H), fill=(12, 12, 12)) # Progress bars centered total = max(1, total) total_w = total * 38 - 14 start = (W - total_w) // 2 for i in range(total): fill = (92, 184, 122) if i == idx else (70, 70, 70) draw.rounded_rectangle((start + i * 38, 820, start + i * 38 + 24, 832), radius=6, fill=fill) brand = 'VNEWS AI SHORT' try: bb = draw.textbbox((0, 0), brand, font=fb); tx = (W - (bb[2] - bb[0])) // 2 except: tx = 360 draw.text((tx, 870), brand, fill=(110, 231, 143), font=fb) seg_text = _strip_bullet_prefix(seg) lines = wrap_text_vn(draw, seg_text, fb, W - 120, 8) if fb else [seg_text[:80]] block_h = len(lines) * 74 y = max(980, 1250 - block_h // 2) _draw_center(draw, lines, fb, y, (255, 255, 255), W, 74) title_lines = wrap_text_vn(draw, _strip_bullet_prefix(post.get('title', '')), fb, W - 120, 3) if fb else [] y2 = 1640 draw.line((80, y2 - 26, W - 80, y2 - 26), fill=(70, 70, 70), width=2) _draw_center(draw, title_lines, fb, y2, (220, 220, 220), W, 42) bg.save(frame_path, quality=92) return True def _source_badge_url_first(post): d = _domain(post.get('url', '')) if d: return d for s in post.get('sources') or []: d = _domain(s.get('url', '')) if d: return d return 'VNEWS' def wrap_text_vn(draw, text, font, maxw, max_lines): words = _clean(text).split() lines = [] cur = '' for w in words: test = (cur + ' ' + w).strip() try: width = draw.textbbox((0, 0), test, font=font)[2] except: width = len(test) * 22 if width <= maxw: cur = test else: if cur: lines.append(cur) cur = w if len(lines) >= max_lines: break if cur and len(lines) < max_lines: lines.append(cur) return lines # ====================================================================== # SHORT VIDEO GENERATOR — FULLY ROBUST # ====================================================================== def _gen_short_sync(post) -> str: """Generate short video NOW. Returns video URL or empty string on failure.""" post_id = post.get('id', '') if not post_id: _log.error("_gen_short_sync: no post_id") return '' text = post.get('text', '') or post.get('title', '') if len(text) < 100: _log.warning(f"_gen_short_sync: text too short ({len(text)})") return '' segments = _split_into_segments(text, max_segments=10, min_len=30) if not segments: _log.error("_gen_short_sync: no segments after splitting") return '' _log.info(f"Segments: {len(segments)} (first: '{segments[0][:50]}...')") seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8] suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub" out_name = f5.base._safe_name(post_id + suffix) + '.mp4' out_mp4 = os.path.join(SHORTS_DIR, out_name) video_url = '/api/ai/short-file/' + post_id + suffix if os.path.exists(out_mp4): _log.info(f"_gen_short_sync: already exists {out_mp4}") return video_url work = os.path.join(SHORTS_DIR, f5.base._safe_name(post_id + suffix)) os.makedirs(work, exist_ok=True) # Download image img_path = os.path.join(work, 'image.jpg') downloaded = False try: img_url = post.get('img', '') if img_url and img_url.startswith('http'): r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15) if r.status_code == 200: with open(img_path, 'wb') as f: f.write(r.content) downloaded = True except Exception as e: _log.warning(f"download image: {e}") # Check PIL/ffmpeg try: from PIL import Image, ImageDraw, ImageFont has_pil = True except Exception as e: _log.warning(f"PIL import: {e}") has_pil = False try: subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) except: _log.error("ffmpeg not found!") return '' try: from gtts import gTTS has_tts = True except Exception as e: _log.warning(f"gTTS: {e}, using silent video") has_tts = False part_files = [] errors = [] for idx, seg in enumerate(segments[:10]): frame_path = os.path.join(work, f'frame_{idx:02d}.jpg') audio_path = os.path.join(work, f'voice_{idx:02d}.mp3') audio_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3') part_path = os.path.join(work, f'part_{idx:02d}.mp4') # ----- Frame ----- frame_ok = False if has_pil: try: _make_frame(post, seg, idx, len(segments), img_path, downloaded, frame_path) if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000: frame_ok = True else: _log.warning(f"frame {idx}: file too small or missing") except Exception as e: _log.error(f"frame {idx} creation: {e}") errors.append(f"frame{idx}: {e}") if not frame_ok: try: # Fallback: ffmpeg solid color frame subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=0x0f1726:s=1080x1920:d=1', '-frames:v', '1', frame_path], capture_output=True, timeout=30) frame_ok = os.path.exists(frame_path) except: continue # ----- Audio ----- dur = 10.0 if has_tts: try: tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()[:300] if tts_text: gTTS(tts_text, lang='vi', slow=False).save(audio_path) # Copy audio directly (no atempo filter — it's a no-op at 1.0 and can fail) subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-vn', '-c:a', 'libmp3lame', audio_fast], check=True, capture_output=True, timeout=90) pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:no_key=1', audio_fast], capture_output=True, timeout=20) dur = max(6.0, float((pr.stdout or b'').decode().strip() or 10.0)) + 1.0 else: dur = 8.0 except Exception as e: _log.warning(f"TTS part {idx}: {e}") errors.append(f"tts{idx}: {e}") dur = 8.0 # ----- Combine frame + audio ----- try: cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame_path] if has_tts and os.path.exists(audio_fast): cmd += ['-i', audio_fast, '-shortest'] else: cmd += ['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-shortest'] cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-preset', 'fast', '-crf', '23', part_path] subprocess.run(cmd, check=True, capture_output=True, timeout=150) if os.path.exists(part_path) and os.path.getsize(part_path) > 5000: part_files.append(part_path) _log.info(f"Part {idx}: {os.path.getsize(part_path)} bytes") else: _log.warning(f"Part {idx}: file too small or missing") except Exception as e: _log.warning(f"ffmpeg part {idx}: {e}") errors.append(f"part{idx}: {e}") if part_files: try: concat_file = os.path.join(work, 'concat.txt') with open(concat_file, 'w', encoding='utf-8') as f: for p in part_files: f.write("file '" + p.replace("'", "'\\''") + "'\n") subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', out_mp4], check=True, capture_output=True, timeout=180) _log.info(f"Short video generated: {out_mp4} ({len(part_files)} parts, {os.path.getsize(out_mp4)} bytes)") post['video'] = video_url post['short_voice'] = 'nu' post['short_emotion'] = 'neutral' post['short_speed'] = 1.0 post['short_segments'] = segments post['short_subtitles'] = False wall = f5.base._load_ai_wall() for i, p in enumerate(wall): if p.get('id') == post_id: wall[i] = post break f5.base._save_ai_wall(wall) return video_url except Exception as e: _log.error(f"concat: {e}") errors.append(f"concat: {e}") else: _log.error(f"No part files generated! Errors: {'; '.join(errors)}") return '' # ===== Auto Scheduler Trigger ===== _AUTO_SCHEDULER_RUNNING = False _AUTO_SCHEDULER_LAST = 0 _AUTO_SCHEDULER_RESULTS = [] def _run_scheduler_now(): """Trigger auto_scheduler._run_scheduled_posting() safely.""" global _AUTO_SCHEDULER_RUNNING, _AUTO_SCHEDULER_LAST, _AUTO_SCHEDULER_RESULTS if _AUTO_SCHEDULER_RUNNING: _log.warning("Scheduler already running!") return {"status": "running", "message": "Scheduler đang chạy, vui lòng đợi."} _AUTO_SCHEDULER_RUNNING = True try: import auto_scheduler as _as _as.LOG.info("Triggered manually via API") _as._run_scheduled_posting() _AUTO_SCHEDULER_LAST = time.time() _AUTO_SCHEDULER_RESULTS = [{'time': datetime.now().strftime('%H:%M %d/%m/%Y'), 'status': 'done'}] _log.info("Auto scheduler completed successfully") return {"status": "done", "message": "Đã đăng 3 bài tự động thành công!"} except Exception as e: _log.error(f"Auto scheduler failed: {e}", exc_info=True) return {"status": "error", "message": f"Lỗi: {e}"} finally: _AUTO_SCHEDULER_RUNNING = False @app.post('/api/auto/schedule') async def _api_auto_schedule(): """Trigger auto-scheduler to post 3 articles NOW.""" _log.info("POST /api/auto/schedule triggered") result = await asyncio.get_event_loop().run_in_executor(None, _run_scheduler_now) return JSONResponse(result) @app.get('/api/auto/status') async def _api_auto_status(): """Check auto-scheduler status.""" return JSONResponse({ 'running': _AUTO_SCHEDULER_RUNNING, 'last_run': _AUTO_SCHEDULER_LAST, 'last_run_str': datetime.fromtimestamp(_AUTO_SCHEDULER_LAST).strftime('%H:%M %d/%m/%Y') if _AUTO_SCHEDULER_LAST else 'never', 'results': _AUTO_SCHEDULER_RESULTS }) _bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False def _bg(): global _bg_lock if _bg_lock:return _bg_lock=True try: if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();(_bg_home.update({"t":time.time(),"d":d}) if d else None) raw=[];[raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20)) for h in f6.YOUTUBE_HANDLES];raw.extend(f6._fallback_shorts()) seen=set();out=[v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])] if out:_bg_shorts.update({"t":time.time(),"d":out[:40]}) _cleanup() except:pass finally:_bg_lock=False @app.on_event("startup") async def _s():threading.Thread(target=_bg,daemon=True).start() threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start() app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/api/ai/short/{post_id}','/api/ai/short-file/{file_id}','/api/auto/schedule','/api/auto/status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))] @app.get('/api/homepage') def _h(): n=time.time() if _bg_home['d']:(threading.Thread(target=_bg,daemon=True).start() if n-_bg_home['t']>300 else None);return JSONResponse(_bg_home['d']) if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();_bg_home.update({"t":n,"d":d or []});return JSONResponse(d or []) return JSONResponse([]) @app.get('/api/shorts') def _sh(refresh:int=Query(default=0)): n=time.time() if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d']) return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([]) @app.get('/api/ai_wall') def _w():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0) 60] if not paragraphs: paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 20] slides = [] for i, pt in enumerate(paragraphs[:8]): slides.append({'text': pt, 'image': og_img, 'index': i + 1}) post=f5.base.make_post(title or 'Bài viết',text,og_img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}]) post['images'] = [og_img] if og_img else [] post['slides'] = slides ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps) if len(text) > 100: t = threading.Thread(target=_gen_short_sync, args=(post,), daemon=True) t.start() return JSONResponse({'post':post}) @app.post('/api/topic/rewrite') async def _tr(request:Request): b=await request.json();pid=str(b.get('post_id','')).strip() if not pid:return JSONResponse({'error':'missing post_id'},status_code=400) ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None) if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404) urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5] parts=[] for u in urls:t,r,_=_scrape(u,6000);(parts.append(f"[{_domain(u)}] {t}\n{r}") if r and len(r)>150 else None) ac='\n---\n'.join(parts) if parts else (p.get('text') or '') title=p.get('title','') text=None try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',image_url=p.get('img'),max_tokens=1200),timeout=35) except:pass if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI" np=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]));np['images']=p.get('images',[]) all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p);return JSONResponse({'post':np}) @app.post('/api/topic_post') async def _tp(request:Request): b=await request.json();topic=clean(b.get('topic','')) if not topic:return JSONResponse({'error':'missing topic'},status_code=400) img=f6._topic_image(topic);research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic) ctx=research.get('context','');src=research.get('sources',[]);det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else [] if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422) sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000] text=None try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35) except:pass if not text or len(text)<300:text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')})) post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post}) PATCH_INJECT=r'''
''' @app.get('/') async def _index(): html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else ('VNEWS' if os.path.exists('/app/static/index.html') else '') body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','') body+=PATCH_INJECT return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body)