diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 40f60e1de89e0f904c11d302058de8c039f587a9..0000000000000000000000000000000000000000
--- a/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-__pycache__/
-*.pyc
-data/
-.data
-.huggingface/
-.restart_trigger
diff --git a/.huggingface/rebuild b/.huggingface/rebuild
new file mode 100644
index 0000000000000000000000000000000000000000..048f645894a0ea68f3604d333a2d43801eaf8be1
--- /dev/null
+++ b/.huggingface/rebuild
@@ -0,0 +1 @@
+rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes
\ No newline at end of file
diff --git a/.rebuild b/.rebuild
new file mode 100644
index 0000000000000000000000000000000000000000..dfbecc973e3f3ce5f2e4b67ace3aefe14422a973
--- /dev/null
+++ b/.rebuild
@@ -0,0 +1 @@
+rebuild 20260630044000
\ No newline at end of file
diff --git a/.restart_trigger b/.restart_trigger
new file mode 100644
index 0000000000000000000000000000000000000000..048f645894a0ea68f3604d333a2d43801eaf8be1
--- /dev/null
+++ b/.restart_trigger
@@ -0,0 +1 @@
+rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bed2a391f57aae4c106228b98824a5a07c71913b..065430a415d73f8c8cf5cdae84396bd20b5e22d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,71 @@
-# FPT Play Stream Selector Update
+# VNEWS v6.5 - Resilient Shorts Auto-Updater
-Added FPT Play channel with stream selector UI similar to VTV6:
+## Changes
+
+### Critical Fix: Shorts timeout and homepage load stability
+**Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
+- Homepage `/api/shorts` endpoint to time out (30s limit)
+- Space to appear unresponsive on first load
+- No fallback when sources fail
+
+**Fix applied**:
+1. **shorts_updater.py** (NEW) — Resilient background updater:
+ - Hard timeout (25s) per channel using subprocess isolation
+ - Stale-while-revalidate pattern: returns cached data immediately, updates in background
+ - Automatic fallback to hardcoded short URLs when all sources fail
+ - Persistent storage in `/data/shorts_cache.json` for cache across restarts
+ - Background scheduler runs every 10 minutes automatically
+ - No blocking on first homepage load
+
+2. **_run.py** — Integrated resilient shorts endpoint:
+ - Overrides `/api/shorts` with non-blocking version
+ - Returns cached/fallback data in <100ms guaranteed
+ - Triggers background update if cache is stale or empty
+ - Never hangs - always returns valid JSON response
+
+3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
+ - baodantri7941 (Dân trí) headlines
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
+ - vtvnambo (VTV Nam Bộ) news
+
+### Benefits
+- Homepage loads in <2 seconds always
+- Shorts data auto-updates every 10 minutes
+- Never times out - graceful degradation to fallback
+- Persistent cache survives Space restarts
+- Uses bucket `bep40/VNEWS-storage` for cache storage
+
+### Channels monitored
+- baodantri7941 (Dân trí)
+- baosuckhoedoisongboyte (Sức khỏe & đời sống)
+- vtvnambo (VTV Nam Bộ)
-- New tab "FPT" (orange themed) in the channel tabs
-- Stream selector with 4 sources:
- 1. 🌐 Web FPT Play (iframe)
- 2. 📡 HLS Proxy (via /api/proxy/m3u8)
- 3. 🔗 HLS Direct
- 4. 📺 HD1.xemtv.net (iframe from LINK 1)
-- Backend vtv_api.py now returns stream_selectors for fpt-the-thao channel
-- Frontend handles stream switching automatically when FPT tab is active
+---
+
+# VNEWS v5.1 - Rewrite Fix
## Changes
-- `static/vtv_init.js`: Added FPT tab + stream selector UI logic
-- `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data
+
+### Critical Fix: Rewrite button not creating posts on Tường AI
+**Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
+
+**Fix applied**:
+1. **app_v2_entry.py** — Added 3 new endpoints:
+ - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
+ - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
+ - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
+ - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
+
+2. **static/index_v2.html** — Added `` to load the rewrite fix
+
+3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
+ - Call `/api/rewrite_slide` first (fast, no AI needed)
+ - Fallback to `/api/rewrite_share` if slide fails
+ - Show slide preview overlay after successful post
+ - Use `prependWallPost()` to add the new post to Tường AI
+
+### Previous changes (v5)
+- Rewrote match_detail_v2.py with correct event parsing
+- 2-tab layout for match detail (stats + timeline)
+- Fixed _run.py import
+- Dockerfile cache busting
diff --git a/Dockerfile b/Dockerfile
index fd799e0b9eca048c628530076b0a14544944a46b..e6aa28a553b6a673b4601737bb1d53e3248d0970 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,47 +2,18 @@ FROM python:3.12-slim
WORKDIR /app
-RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \
- apt-get update && apt-get install -y --no-install-recommends \
- ffmpeg \
- fonts-dejavu-core \
- fonts-noto \
- fonts-noto-cjk \
- fonts-noto-color-emoji \
- fonts-liberation \
- fonts-freefont-ttf \
- libfreetype6 \
- && rm -rf /var/lib/apt/lists/* && \
- echo "[BUILD] step1 done"
-
-RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
- pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \
- echo "[BUILD] step2 done"
-
-RUN echo "[BUILD] step3: pip main pkgs" && \
- pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \
- echo "[BUILD] step3 done"
+RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
+RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml
+RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
COPY requirements.txt .
-RUN echo "[BUILD] step4: pip requirements.txt" && \
- pip install --no-cache-dir -r requirements.txt || true && \
- echo "[BUILD] step4 done"
+RUN pip install --no-cache-dir -r requirements.txt || true
COPY . .
EXPOSE 7860
-RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \
- mkdir -p /usr/share/fonts/truetype/vn && \
- # Prefer Noto Sans for Vietnamese - it has full diacritic support
- if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \
- ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
- elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \
- fi; \
- fc-cache -f -v || true; \
- date > /app/.build_done && \
- echo "[BUILD] step5 done"
-
-CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
-# v3.0-vn-font-fix-short-video-2026-07-19
+# v6 - 2026-06-28 18:45 - voice selector fix + cache bust
+CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
+# build 1782698397
+# build 1782698798
+# build 1782699174
diff --git a/README.md b/README.md
index f65ec063b0184f66ec5c41d94307c4f38f4c0a38..dc357a7ef1b414efed6580b6ca6d3ed5e257e4fe 100644
--- a/README.md
+++ b/README.md
@@ -9,50 +9,17 @@ tags:
- ml-intern
---
-# VNEWS - Tin Tức Việt Nam
-
-**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
-
-## 🔧 Changes in v18 (2026-07-06)
-- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
-- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, ``, url:, window.location, iframe follow (3 levels deep), base64 decode
-- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
-- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
-- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
-- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
-
-## Features:
-- 📰 News from VnExpress (10 categories) + GenK AI
-- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
-- 🎬 Football highlights from xemlaibongda.top (8 leagues)
-- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
- - Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
-- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
-- 🤖 AI article writing + TTS (multilingual, emotion-aware)
-- 🔍 Topic search (8 news sources)
-- 🎤 TTS: voice selector + emotion selector + speed control
-
-## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức)
-
-Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok /
-VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short
-chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc.
-
-### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot")
-YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies:
-
-1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc
- **"cookies.txt"** (Firefox).
-2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape).
-3. Đưa cookies vào Space bằng **một trong hai cách**:
- - **Cách A (khuyến nghị):** Vào Settings của Space
- `huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** →
- tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`.
- - **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit
- (chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A).
-4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space).
-
-Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm
-và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code.
-
-> Lưu ý: cookies có hạn (thường vài tuần). Khi hết hạn, export lại và cập nhật secret.
\ No newline at end of file
+# bep40/vnews
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RESTART_TRIGGER.md b/RESTART_TRIGGER.md
deleted file mode 100644
index e6c79c75d2068b3dc20f52912afac9b21944b2d0..0000000000000000000000000000000000000000
--- a/RESTART_TRIGGER.md
+++ /dev/null
@@ -1,7 +0,0 @@
-trigger rebuild v20260827fptwall
-
-- FPT wall cards now use .wall-thumb 100% width/height (16:9), identical to Short AI cards
-- FPT player opens a scrollable vertical feed interleaving FPT Play + Short AI videos, newest-first
-- 16:9 <-> 9:16 ratio toggle (🖥️/📺) works on football highlight slides and Tường AI slide player
-- Cache-busting: app_v2.js?v=20260827fptwall
-- FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js
\ No newline at end of file
diff --git a/_run.py b/_run.py
index de72380c3006dba490ec49c1fe421c87723a0490..4a7a26bd2553b700d918ace5f77e2a3e369e0be2 100644
--- a/_run.py
+++ b/_run.py
@@ -1 +1 @@
-from app_v2_entry import app # v5-stable inline bongda proxy
\ No newline at end of file
+from app_v2_entry import app # v5-stable inline bongda proxy
diff --git a/ai_ext.py b/ai_ext.py
index ab7540db0765711cf34947d481888084e0f87c65..cf4b1d0ff3215baa8ed3f05887a252c47d56bc90 100644
--- a/ai_ext.py
+++ b/ai_ext.py
@@ -96,20 +96,27 @@ def _domain(url: str) -> str:
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
- """Generate text using Llama/Qwen models via Hugging Face Inference API.
+ """Generate text using Qwen models via Hugging Face Inference API.
- Prioritizes Llama-3.3-70B for better creative/opinion writing.
+ This function provides a resilient implementation that:
+ 1. First tries the SDK-based inference client if available
+ 2. Falls back to REST API calls to HF router endpoint
+ 3. Returns a fallback summary if all else fails
"""
token = _hf_token()
errors = []
- # Try HF router API with multiple models - Llama FIRST for opinion writing
+ # Try HF router API with multiple models
if token:
models = [
os.getenv("QWEN_VL_MODEL", ""),
- "meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
"Qwen/Qwen2.5-VL-7B-Instruct",
+ "Qwen/Qwen2.5-VL-3B-Instruct",
+ "Qwen/Qwen2.5-7B-Instruct",
+ "Qwen/Qwen2.5-3B-Instruct",
+ "Qwen/Qwen2.5-1.5B-Instruct",
"Qwen/Qwen2.5-72B-Instruct",
+ "meta-llama/Llama-3.3-70B-Instruct",
]
# Deduplicate while preserving order
seen = set()
@@ -131,12 +138,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
payload = {
"model": model,
"messages": [
- {"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."},
+ {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
{"role": "user", "content": user_content},
],
- "max_tokens": min(int(max_tokens or 2000), 2500),
- "temperature": 0.75,
- "top_p": 0.9,
+ "max_tokens": min(int(max_tokens or 900), 1400),
+ "temperature": 0.35,
+ "top_p": 0.85,
}
r = requests.post(
@@ -176,18 +183,20 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
text = re.sub(r"https?://\S+", "", text)
text = re.sub(r"\s+", " ", text).strip()
- # Split into sentences
+ # Split into sentences - extract ALL valid sentences, not just first few
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
units = []
for s in sentences:
s = _clean_text(s)
- if len(s) >= 30:
+ if len(s) >= 30: # Lower threshold to capture more content
units.append(s)
if units:
+ # Take up to max_units valid sentences
result_units = units[:max_units]
return "\n".join("• " + u for u in result_units)
if text:
+ # Fallback: take chunks if no sentence boundaries found
chunks = []
for i in range(0, min(len(text), max_units * 300), 280):
chunk = _clean_text(text[i:i+300])
@@ -199,134 +208,367 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
return "\n".join("• " + c for c in chunks)
return "• Không có đủ nội dung để tóm tắt."
-# ===== URL scraping & article processing =====
-HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
-try:
- _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
-except Exception:
- _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
-SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
-os.makedirs(SHORTS_DIR, exist_ok=True)
+HF_TOKEN = _hf_token()
+QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
+QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
+ "QWEN_TEXT_MODELS",
+ "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
+).split(",") if m.strip()]
+_WORKING_MODEL_TEXT = None
+_WORKING_MODEL_VL = None
+DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
+SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
+HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
+}
+LAST_QWEN_ERROR = ""
+
+
+# ===== MULTILINGUAL VOICES FOR TTS =====
+# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
+MULTILINGUAL_VOICES = {
+ # Vietnamese - Native voices
+ "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
+ "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
+ "hoaimy": "vi-VN-HoaiMyNeural",
+ "namminh": "vi-VN-NamMinhNeural",
+ "vi_female": "vi-VN-HoaiMyNeural",
+ "vi_male": "vi-VN-NamMinhNeural",
+ "nu": "vi-VN-HoaiMyNeural",
+ "male": "vi-VN-NamMinhNeural",
+ "female": "vi-VN-HoaiMyNeural",
+ "mien-nam": "vi-VN-HoaiMyNeural",
+ # English - Multilingual
+ "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
+ "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
+ "en_andrew": "en-US-AndrewMultilingualNeural",
+ "andrew": "en-US-AndrewMultilingualNeural",
+ "en_jenny": "en-US-AndrewMultilingualNeural",
+ "jenny": "en-US-AndrewMultilingualNeural",
+ # Portuguese - Thalita Multilingual ONLY
+ "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
+ "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
+ "thalita": "pt-BR-ThalitaMultilingualNeural",
+ "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
+ "pt": "pt-BR-ThalitaMultilingualNeural",
+ # French - Multilingual
+ "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
+ "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
+ "fr_denise": "fr-FR-VivienneMultilingualNeural",
+ "denise": "fr-FR-VivienneMultilingualNeural",
+ "fr": "fr-FR-VivienneMultilingualNeural",
+ # German - Multilingual
+ "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
+ "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
+ "de_katja": "de-DE-SeraphinaMultilingualNeural",
+ "katja": "de-DE-SeraphinaMultilingualNeural",
+ "de": "de-DE-SeraphinaMultilingualNeural",
+ # Korean - Hyunsu Multilingual (NOT SunHee)
+ "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
+ "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
+ "sunhee": "ko-KR-HyunsuMultilingualNeural",
+ "ko": "ko-KR-HyunsuMultilingualNeural",
+ # Italian - Multilingual
+ "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
+ # Spanish (fallback to English multilingual)
+ "es_ela": "en-US-AndrewMultilingualNeural",
+ "ela": "en-US-AndrewMultilingualNeural",
+ "es_carlos": "en-US-AndrewMultilingualNeural",
+ "es": "en-US-AndrewMultilingualNeural",
+ # Japanese (fallback to English multilingual)
+ "ja_nanami": "en-US-AndrewMultilingualNeural",
+ "nanami": "en-US-AndrewMultilingualNeural",
+ "ja": "en-US-AndrewMultilingualNeural",
+ # Chinese (fallback to English multilingual)
+ "zh_xiaochen": "en-US-AndrewMultilingualNeural",
+ "xiaochen": "en-US-AndrewMultilingualNeural",
+ "zh": "en-US-AndrewMultilingualNeural",
+}
+
-import random as _random2
-from datetime import datetime, timezone, timedelta
-_VN_TZ = timezone(timedelta(hours=7))
+def _detect_voice_emotion(title, text):
+ """Detect appropriate voice and emotion based on content for multilingual TTS."""
+ content = ((title or "") + " " + (text or "")).lower()
+
+ # World Cup / Football content - use Andrew multilingual
+ if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
+ return ("andrew", "excited")
+
+ # News categories - choose appropriate voice
+ if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
+ return ("jenny", "calm")
+ if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
+ return ("thalita", "serious")
+ if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
+ return ("ela", "happy")
+ if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
+ return ("katja", "excited")
+
+ # Default Vietnamese
+ return ("hoaimy", "trung_tinh")
-def _safe_name(filename: str) -> str:
- """Sanitize filename."""
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
+def _safe_name(s: str) -> str:
+ """Create safe filename from string."""
+ s = re.sub(r"[^\w\-.]", "_", s)
+ return s[:100] if len(s) > 100 else s
-def pollinations_image_url(topic: str) -> str:
- """Generate a placeholder image URL via Pollinations."""
+def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
+ """Download image from URL to path."""
+ if not url:
+ return False
try:
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
+ r = requests.get(url, headers=HEADERS, timeout=15)
+ if r.status_code == 200:
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "wb") as f:
+ f.write(r.content)
+ return True
except Exception:
- return ""
+ pass
+ return False
-def _download_image(url: str, fallback_title: str, out_path: str) -> str:
- """Download an image from URL or create a placeholder."""
- if url:
- try:
- r = requests.get(url, headers=HEADERS, timeout=15)
- if r.status_code == 200 and len(r.content) > 1200:
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
- with open(out_path, "wb") as f:
- f.write(r.content)
- return out_path
- except Exception:
- pass
- # Fallback: create a placeholder image
+def pollination_image_url(topic: str) -> str:
+ """Generate image URL from Pollinations.ai."""
+ return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
+
+
+# Use the same wall file as app_v2_entry.py for consistency
+WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
+
+def _load_ai_wall():
+ """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
try:
- from PIL import Image, ImageDraw, ImageFont
- img = Image.new("RGB", (1080, 760), (24, 24, 24))
- draw = ImageDraw.Draw(img)
- try:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
- except Exception:
- font = None
- text = (fallback_title or "VNEWS")[:40]
- try:
- bbox = draw.textbbox((0, 0), text, font=font)
- tw = bbox[2] - bbox[0]
- except Exception:
- tw = len(text) * 24
- draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
- img.save(out_path, quality=90)
- return out_path
+ if os.path.exists(WALL_FILE):
+ with open(WALL_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
except Exception:
- return out_path
+ pass
+ return []
+def _save_ai_wall(posts):
+ """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
+ try:
+ os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
+ tmp = WALL_FILE + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(posts[:100], f, ensure_ascii=False)
+ os.replace(tmp, WALL_FILE)
+ except Exception:
+ pass
+
+
+# Helper functions for wall operations
+def _load_wall_posts():
+ """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
+ return _load_ai_wall()
+
+
+def _save_wall_posts(posts):
+ """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
+ return _save_ai_wall(posts)
+
+
+def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
+ """Create a post dict with standard fields."""
+ return {
+ "id": str(int(time.time() * 1000)),
+ "title": title,
+ "text": text,
+ "img": img,
+ "url": url,
+ "kind": kind,
+ "sources": sources or [],
+ "ts": int(time.time())
+ }
+
+
+def _short_script(post) -> str:
+ """Extract clean text for TTS from post."""
+ text = post.get("text", "") or post.get("title", "")
+ text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
+ text = re.sub(r"\s*\n\s*", ". ", text)
+ return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
+
+
+# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
def scrape_any_url(url: str) -> dict:
- """Scrape article content from any URL."""
- if not url or not url.startswith("http"):
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
+ """Scrape any URL and extract article content.
+
+ Returns dict with: title, summary, text, image, og_image, via (domain)
+ """
try:
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
- if r.status_code != 200 or not r.text:
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
- r.encoding = "utf-8"
- soup = BeautifulSoup(r.text, "lxml")
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript", "iframe", ".ads", ".ad", ".banner-ads", ".fb-comments", ".fb-root", ".social-share", ".related-news", ".breadcrumb"]):
+ r.encoding = 'utf-8'
+ soup = BeautifulSoup(r.text, 'lxml')
+
+ # Remove scripts, styles, nav, footer
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
tag.decompose()
- title = ""
- ogt = soup.find("meta", property="og:title")
- if ogt:
- title = ogt.get("content", "")
- h1 = soup.find("h1")
- if not title and h1:
- title = h1.get_text(strip=True)
- if not title:
- t = soup.find("title")
- if t:
- title = t.get_text(strip=True)
- og_image = ""
- ogi = soup.find("meta", property="og:image")
- if ogi:
- og_image = ogi.get("content", "")
- if og_image.startswith("//"):
- og_image = "https:" + og_image
- summary = ""
- ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
- if ogd:
- summary = ogd.get("content", "")[:500]
- body_text = []
- for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
+
+ # Extract title
+ h1 = soup.find('h1')
+ ogt = soup.find('meta', property='og:title')
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
+
+ # Extract OG image
+ ogi = soup.find('meta', property='og:image')
+ og_image = ogi.get('content', '') if ogi else ''
+
+ # Extract article body
+ block = None
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
el = soup.select_one(sel)
- if el and len(el.find_all("p")) >= 2:
- for p in el.find_all("p"):
- t = _clean_text(p.get_text(strip=True))
- if t and len(t) > 30:
- body_text.append(t)
+ if el and len(el.find_all('p')) >= 2:
+ block = el
break
- if not body_text and soup.body:
- for p in soup.body.find_all("p"):
- t = _clean_text(p.get_text(strip=True))
- if t and len(t) > 30:
- body_text.append(t)
- text = "\n".join(body_text)
- return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
+ if not block:
+ block = soup.body or soup
+
+ # Extract text from paragraphs
+ paragraphs = []
+ for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
+ t = _clean_text(el.get_text(strip=True))
+ if t and len(t) > 40:
+ paragraphs.append(t)
+
+ # Extract images
+ images = []
+ for el in block.find_all(['figure', 'img'], recursive=True):
+ im = el if el.name == 'img' else el.find('img')
+ if im:
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
+ if src and 'base64' not in src:
+ if src.startswith('//'):
+ src = 'https:' + src
+ images.append(src)
+
+ # Prefer OG image as main image
+ image = og_image or (images[0] if images else '')
+
+ return {
+ 'title': title,
+ 'summary': paragraphs[0] if paragraphs else '',
+ 'text': '\n'.join(paragraphs),
+ 'image': image,
+ 'og_image': og_image,
+ 'via': _domain(url),
+ 'images': images
+ }
except Exception as e:
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
+ return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
-def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
- """Create a wall post dict."""
- import random as _r2
- now = int(time.time() * 1000)
- return {
- "id": str(now) + str(_r2.randint(100, 999)),
- "title": (title or "Bài viết")[:200],
- "text": (text or "")[:5000],
- "img": img or "",
- "url": url or "",
- "kind": kind or "auto",
- "sources": sources or [],
- "created": now,
- "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
- }
+def web_context(topic: str, limit: int = 5) -> tuple:
+ """Get web context for a topic. Returns (context_text, sources_list)."""
+ sources = []
+ try:
+ # Try Google News RSS
+ rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
+ r = requests.get(rss_url, headers=HEADERS, timeout=15)
+ r.encoding = 'utf-8'
+ soup = BeautifulSoup(r.text, 'xml')
+ for it in soup.find_all('item')[:limit]:
+ title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
+ link = it.find('link').get_text(strip=True) if it.find('link') else ''
+ if title and link:
+ sources.append({'title': title, 'url': link, 'via': _domain(link)})
+ except Exception:
+ pass
+
+ context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
+ return context, sources
+
+
+# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
+def _make_short_frame(post, img_path, out_path):
+ """Create a short video frame from post and image.
+
+ Called by ai_patch.py _make_short_frame_full when Image is available.
+ """
+ if Image is None:
+ # Create a minimal frame without PIL - just return success
+ # The caller should handle this case
+ return False
+
+ W, H = 1080, 1920
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
+
+ try:
+ im = Image.open(img_path).convert("RGB")
+ target = (1080, 760)
+ im_ratio = im.width / max(1, im.height)
+ target_ratio = target[0] / target[1]
+
+ if im_ratio > target_ratio:
+ new_h = target[1]
+ new_w = int(new_h * im_ratio)
+ else:
+ new_w = target[0]
+ new_h = int(new_w / im_ratio)
+
+ im = im.resize((new_w, new_h))
+ left = (new_w - target[0]) // 2
+ top = (new_h - target[1]) // 2
+ im = im.crop((left, top, left + target[0], top + target[1]))
+ bg.paste(im, (0, 0))
+ except Exception:
+ pass
+
+ draw = ImageDraw.Draw(bg)
+
+ try:
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
+ except Exception:
+ font_title = font_body = None
+
+ draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
+ margin = 48
+ maxw = W - margin * 2
+
+ y = 830
+ for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
+ draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
+ y += 66
+
+ y += 18
+ text = post.get("text", "")
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
+ body_lines = _wrap_text(draw, text, font_body, maxw, 14)
+ for ln in body_lines:
+ draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
+ y += 50
+ if y > 1640:
+ break
+
+ bg.save(out_path, quality=92)
+ return True
+
+
+def _wrap_text(draw, text, font, max_width, max_lines):
+ """Helper for wrapping text in frames."""
+ words = _clean_text(text).split()
+ lines, cur = [], ""
+ for w in words:
+ test = (cur + " " + w).strip()
+ try:
+ width = draw.textbbox((0, 0), test, font=font)[2]
+ except Exception:
+ width = len(test) * 20
+ if width <= max_width:
+ 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
\ No newline at end of file
diff --git a/ai_patch.py b/ai_patch.py
index 41aeba3d810744429c14e473c32c3a9fb8e3a605..a1ef29df2d0a94076dabec10f6cfbb90445cf76d 100644
--- a/ai_patch.py
+++ b/ai_patch.py
@@ -327,24 +327,6 @@ Nội dung bài:
if 'Nguồn tham khảo:' not in text:
text += "\n\n" + _source_line(src)
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)
-
- # Generate slides for this post so they persist after page reload
- try:
- page_data = _scrape_article_images(art.get('url', ''))
- if page_data and page_data.get('paragraphs'):
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
- if key_points:
- relevant_imgs = page_data.get('images', [])
- if not relevant_imgs and page_data.get('og_img'):
- relevant_imgs = [page_data['og_img']]
- slides = []
- for i, point in enumerate(key_points):
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
- slides.append({'text': point, 'image': img, 'index': i + 1})
- post['slides'] = slides
- except Exception:
- pass
-
new_posts.append(post)
posts = new_posts + posts
base._save_ai_wall(posts)
@@ -371,26 +353,8 @@ async def compat_url_wall(request: Request):
if 'Nguồn tham khảo:' not in text:
text += "\n\n" + _source_line(src)
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
-
- # Generate slides so they persist after page reload
- slides = []
- try:
- page_data = _scrape_article_images(url)
- if page_data and page_data.get('paragraphs'):
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
- if key_points:
- relevant_imgs = page_data.get('images', [])
- if not relevant_imgs and page_data.get('og_img'):
- relevant_imgs = [page_data['og_img']]
- for i, point in enumerate(key_points):
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
- slides.append({'text': point, 'image': img, 'index': i + 1})
- except Exception:
- pass
- post['slides'] = slides
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
- return JSONResponse({'post': post, 'slides': slides})
+ return JSONResponse({'post': post})
def _is_relevant_image(img_url, title, text):
@@ -523,6 +487,7 @@ async def compat_rewrite_share(request: Request):
if 'Nguồn tham khảo:' not in text:
text += "\n\n" + _source_line(src)
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
# Generate slides with relevant images only
slides = []
@@ -537,10 +502,6 @@ async def compat_rewrite_share(request: Request):
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
slides.append({'text': point, 'image': img, 'index': i + 1})
- # FIX: Save slides into post so they persist after page reload
- post['slides'] = slides
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
-
return JSONResponse({'post': post, 'slides': slides})
diff --git a/ai_runtime_fix.py b/ai_runtime_fix.py
deleted file mode 100644
index 0ec96f7c3c57d95b682765689d6ed0964058cd85..0000000000000000000000000000000000000000
--- a/ai_runtime_fix.py
+++ /dev/null
@@ -1,394 +0,0 @@
-"""VNEWS Short Video Fix - standalone module with clean registration.
-This module MUST be imported LAST to register /api/ai/short endpoints.
-FIX v1: No route filtering issues - registers endpoints unconditionally.
-FIX v2: SSE inline endpoint for auto homepage updates
-"""
-import os
-import re
-import time
-import json
-import sys
-import logging
-import asyncio
-import hashlib
-import subprocess
-import requests
-from datetime import datetime, timezone, timedelta
-from urllib.parse import urlparse
-from fastapi import Request, Query
-from fastapi.responses import JSONResponse, FileResponse
-
-# Import dependencies
-try:
- import ai_ext as base
-except ImportError:
- import ai_runtime_final6 as base
-
-# Try to import app from various sources
-try:
- from app_v2_entry import app
-except ImportError:
- try:
- from main import app
- except ImportError:
- from ai_runtime_final6 import app
-
-_log = logging.getLogger("short_fix")
-_log.setLevel(logging.INFO)
-if not _log.handlers:
- _log.addHandler(logging.StreamHandler(sys.stderr))
-
-DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
-os.makedirs(DATA_DIR, exist_ok=True)
-SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
-os.makedirs(SHORTS_DIR, exist_ok=True)
-
-# ===== VIETNAMESE FONT DETECTION =====
-_VN_FONT_REG = None
-_VN_FONT_BOLD = None
-
-def _get_vn_fonts():
- """Find Vietnamese-supporting fonts."""
- global _VN_FONT_REG, _VN_FONT_BOLD
- if _VN_FONT_REG is not None:
- return _VN_FONT_REG, _VN_FONT_BOLD
-
- try:
- from PIL import ImageFont
- except Exception:
- _log.error("PIL not available!")
- return None, None
-
- # Priority: Noto > DejaVu > Liberation
- reg_paths = [
- "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
- "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
- ]
- bold_paths = [
- "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
- "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
- "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
- ]
-
- for path in reg_paths:
- if os.path.exists(path):
- try:
- _VN_FONT_REG = ImageFont.truetype(path, 40)
- _log.info(f"Found regular font: {path}")
- break
- except:
- continue
-
- for path in bold_paths:
- if os.path.exists(path):
- try:
- _VN_FONT_BOLD = ImageFont.truetype(path, 52)
- _log.info(f"Found bold font: {path}")
- break
- except:
- continue
-
- if _VN_FONT_REG is None:
- _VN_FONT_REG = ImageFont.load_default()
- if _VN_FONT_BOLD is None:
- _VN_FONT_BOLD = _VN_FONT_REG
-
- return _VN_FONT_REG, _VN_FONT_BOLD
-
-
-def _clean(s):
- import html as html_lib
- return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip()
-
-
-# ===== ROBUST TEXT SEGMENTATION =====
-def _split_into_segments(text, max_segments=10, min_len=30):
- """Split text into segments - multi strategy."""
- text = _clean(text)
- if not text:
- return []
-
- # Strategy 1: bullet points
- lines = text.split('\n')
- segmented = []
- for line in lines:
- line = _clean(line)
- line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
- if len(line_bare) > min_len:
- segmented.append(line_bare)
- elif len(line) > min_len:
- segmented.append(line)
-
- # Strategy 2: sentences (Vietnamese)
- if len(segmented) < 2:
- sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
- segmented = [s for s in sents if len(_clean(s)) > min_len]
-
- # Strategy 3: character chunks
- if not segmented:
- words = text.split()
- for i in range(0, min(len(words), max_segments * 20), 20):
- chunk = ' '.join(words[i:i+20])
- if len(chunk) > min_len:
- segmented.append(chunk)
-
- # Strategy 4: fallback
- if not segmented:
- segmented = [text[:300]]
-
- return segmented[:max_segments]
-
-
-# ===== SHORT VIDEO GENERATOR =====
-def _gen_short_core(post, work_dir):
- """Core short generation - returns video path or None."""
- post_id = post.get('id', '')
- text = post.get('text', '') or post.get('title', '')
-
- if not post_id or len(text) < 100:
- _log.error(f"Invalid post: id={post_id}, text_len={len(text)}")
- return None
-
- segments = _split_into_segments(text, max_segments=10, min_len=30)
- if not segments:
- _log.error("No segments generated")
- return None
-
- _log.info(f"Generating short: {len(segments)} segments")
-
- seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8]
- suffix = f"_nu_{seg_hash}"
- out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4")
-
- if os.path.exists(out_mp4):
- _log.info(f"Already exists: {out_mp4}")
- return out_mp4
-
- # Check dependencies
- try:
- subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
- except Exception as e:
- _log.error(f"ffmpeg missing: {e}")
- return None
-
- # Download image
- img_path = os.path.join(work_dir, 'bg.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=12)
- 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"Image download: {e}")
-
- try:
- from PIL import Image, ImageDraw
- has_pil = True
- except:
- has_pil = False
- _log.warning("PIL not available")
-
- try:
- from gtts import gTTS
- has_tts = True
- except:
- has_tts = False
- _log.warning("gTTS not available")
-
- parts = []
-
- for i, seg in enumerate(segments[:10]):
- frame = os.path.join(work_dir, f'frame_{i}.jpg')
- audio = os.path.join(work_dir, f'audio_{i}.mp3')
- part = os.path.join(work_dir, f'part_{i}.mp4')
-
- # Create frame
- try:
- if has_pil:
- _make_frame(post, seg, img_path, downloaded, frame)
- else:
- subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i',
- 'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame],
- capture_output=True, timeout=20)
- except Exception as e:
- _log.error(f"Frame error: {e}")
- continue
-
- # Create audio
- if has_tts:
- try:
- tts = _clean(seg)[:300]
- gTTS(tts, lang='vi', slow=False).save(audio)
- except Exception as e:
- _log.warning(f"TTS error: {e}")
- audio = None
-
- # Combine
- dur = 10
- try:
- cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame]
- if has_tts and os.path.exists(audio):
- cmd += ['-i', audio, '-shortest']
- else:
- cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest']
- cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
- '-c:a', 'aac', '-b:a', '128k', part]
- subprocess.run(cmd, capture_output=True, timeout=120)
- if os.path.exists(part) and os.path.getsize(part) > 5000:
- parts.append(part)
- except Exception as e:
- _log.error(f"Part combine error: {e}")
-
- if not parts:
- _log.error("No video parts created!")
- return None
-
- # Concatenate
- try:
- concat = os.path.join(work_dir, 'list.txt')
- with open(concat, 'w') as f:
- for p in parts:
- f.write(f"file '{p}'\n")
- subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4],
- capture_output=True, timeout=180)
- _log.info(f"Short created: {out_mp4}")
- return out_mp4
- except Exception as e:
- _log.error(f"Concat error: {e}")
- return None
-
-
-def _make_frame(post, text, img_path, downloaded, out_path):
- """Create video frame with Vietnamese font."""
- from PIL import Image, ImageDraw
- _get_vn_fonts()
-
- W, H = 1080, 1920
- bg = Image.new('RGB', (W, H), (15, 23, 38))
- d = ImageDraw.Draw(bg)
-
- # Background image
- if downloaded and os.path.exists(img_path):
- try:
- im = Image.open(img_path).convert('RGB')
- im = im.resize((W, 760))
- bg.paste(im, (0, 0))
- except:
- pass
-
- # Title
- d.rectangle([0, 0, W, 100], fill=(25, 118, 210))
- ttl = post.get('title', '')[:50]
- if _VN_FONT_BOLD:
- d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm')
-
- # Content
- y = 150
- for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10):
- d.text((80, y), ln, fill='white', font=_VN_FONT_REG)
- y += 55
-
- bg.save(out_path, quality=85)
-
-
-def _wrap_text(draw, text, font, x, max_w, max_lines):
- """Word wrap text."""
- words = text.split()
- lines = []
- cur = []
- for w in words:
- test = ' '.join(cur + [w])
- try:
- w_px = draw.textbbox((0, 0), test, font=font)[2]
- except:
- w_px = len(test) * 22
- if w_px <= max_w:
- cur.append(w)
- else:
- if cur:
- lines.append(' '.join(cur))
- cur = [w]
- if len(lines) >= max_lines:
- break
- if cur and len(lines) < max_lines:
- lines.append(' '.join(cur))
- return lines
-
-
-def _gen_short_sync(post) -> str:
- """Sync wrapper - returns video URL."""
- work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}")
- os.makedirs(work, exist_ok=True)
- result = _gen_short_core(post, work)
- if result:
- # Update wall
- try:
- wall = base._load_ai_wall()
- for i, p in enumerate(wall):
- if str(p.get('id')) == str(post.get('id')):
- p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}'
- wall[i] = p
- break
- base._save_ai_wall(wall)
- # Notify SSE for auto-update
- try:
- from auto_update_sse import notify_new_short
- notify_new_short(post)
- except:
- pass
- except Exception as e:
- _log.warning(f"Wall update: {e}")
- return result
- return ''
-
-
-# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL =====
-@app.post('/api/ai/short/{post_id}')
-async def api_short_generate(post_id: str, request: Request):
- _log.info(f"POST /api/ai/short/{post_id}")
- wall = base._load_ai_wall()
- post = next((p for p in wall if str(p.get('id')) == str(post_id)), None)
- if not post:
- return JSONResponse({'error': 'Post not found in wall'}, status_code=404)
-
- if post.get('video'):
- return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
-
- loop = asyncio.get_event_loop()
- result = await loop.run_in_executor(None, _gen_short_sync, post)
-
- if result:
- # Get the video URL from wall (updated in _gen_short_sync)
- wall = base._load_ai_wall()
- post = next((p for p in wall if str(p.get('id')) == str(post_id)), post)
- return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'})
- return JSONResponse({'error': 'Video generation failed'}, status_code=500)
-
-
-@app.get('/api/ai/short-file/{file_id:path}')
-async def api_short_file(file_id: str):
- safe = re.sub(r'[^\w\-.]', '_', file_id)[:100]
- for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []:
- if fname.endswith('.mp4') and safe in fname:
- return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
- return JSONResponse({'error': 'Not found'}, status_code=404)
-
-
-# ===== SSE ENDPOINT FOR AUTO-UPDATE =====
-try:
- from auto_update_sse import sse_events as _sse_handler
- app.add_api_route('/api/events', _sse_handler, methods=['GET'])
- _log.info("SSE endpoint registered at /api/events")
-except Exception as e:
- _log.warning(f"SSE route not loaded: {e}")
-
-
-# Log startup
-_log.info("Short video endpoints registered")
\ No newline at end of file
diff --git a/ai_runtime_patch_final.py b/ai_runtime_patch_final.py
deleted file mode 100644
index 0512ad0ef58446a2f791f8f85e0457950cdd54d3..0000000000000000000000000000000000000000
--- a/ai_runtime_patch_final.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Final patch: homepage fix + AI topics at top + SSE auto-update"""
-import re, json, time
-from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi import Query
-
-# Import chain - must be after ai_runtime_final6
-try:
- import ai_runtime_final6 as f6
- from ai_runtime_final6 import app, f5
- from main import rt
-except Exception as e:
- print(f"[ERROR] f6 import: {e}")
- f6 = None
- f5 = None
- rt = None
-
-PATCH_CSS_JS = r'''
-
-
-
-'''
-
-# Register route if possible
-if f6 and app:
- try:
- # Remove duplicate / route to avoid conflict
- original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
- app.router.routes = original_routes
-
- @app.get('/')
- async def patch_homepage():
- html = f5.f4.f3.f2.f1._load_index_html() if f5 else ""
- body = ""
- if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'):
- body += getattr(rt.old,'PATCH_INJECT','')
- if f5:
- body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else ''
- body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else ''
- body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else ''
- body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else ''
- body += getattr(f6,'FINAL6_INJECT','') if f6 else ''
- body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else ''
- body += getattr(f6,'FINAL6E_INJECT','') if f6 else ''
- body += PATCH_CSS_JS
- if '