"""Final patch v5: SYNCHRONOUS short video generation, no silent errors, log everything. + Auto scheduler trigger.""" 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, FileResponse, Request, Query 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) 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 = [] for line in text.split('\n'): line = clean(line) if line and len(line) > 20: segments.append(line) if not segments: segments = [text[:300]] segments = segments[:10] 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 = [] 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 ----- if has_pil: try: sz = (1080, 1920) bg = Image.new('RGB', sz, (15, 23, 38)) draw = ImageDraw.Draw(bg) if downloaded: try: im = Image.open(img_path).convert('RGB') im = im.resize((1080, 760)) bg.paste(im, (0, 0)) overlay = Image.new('RGBA', (1080, 400), (0, 0, 0, 180)) bg.paste(overlay, (0, 720), overlay) except Exception as e: _log.warning(f"paste img: {e}") try: font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52) font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40) except: font_title = font_body = None draw.rectangle([0, 0, sz[0], 100], fill=(25, 118, 210)) ttl = post.get('title', '')[:50] if font_title: draw.text((sz[0]//2, 50), ttl, fill='white', font=font_title, anchor='mm') y = 820 seg_text = seg[:150] lines = [] for w in seg_text.split(): cur = lines[-1] if lines else '' test = (cur + ' ' + w).strip() try: tw = draw.textbbox((0,0), test, font=font_body)[2] if font_body else len(test)*22 except: tw = len(test)*22 if lines and tw <= 920: lines[-1] = test else: lines.append(test) if len(lines) >= 6: break for ln in lines: if font_body: draw.text((80, y), ln, fill=(255,255,255), font=font_body) y += 55 bg.save(frame_path, quality=88) except Exception as e: _log.error(f"frame {idx} creation: {e}") try: from PIL import Image as Img2 Img2.new('RGB', (1080,1920), (15,23,38)).save(frame_path, quality=85) except: continue else: try: subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', f'color=c=0x0f1726:s=1080x1920:d=10', '-frames:v', '1', frame_path], capture_output=True, timeout=30) 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) subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-filter:a', 'atempo=1.0', '-vn', 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}") dur = 8.0 # ----- Combine ----- 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', '-shortest'] cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', part_path] subprocess.run(cmd, check=True, capture_output=True, timeout=150) part_files.append(part_path) except Exception as e: _log.warning(f"ffmpeg 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)") 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}") else: _log.error("No part files generated!") 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=f5.f4.f3.f2.f1._load_index_html() 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)