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..f21d842ee947ba47cf302f26bde1d185298eb2f8
--- /dev/null
+++ b/.huggingface/rebuild
@@ -0,0 +1 @@
+trigger rebuild
\ No newline at end of file
diff --git a/.rebuild b/.rebuild
new file mode 100644
index 0000000000000000000000000000000000000000..d1e9c67432345af8a80936be5174cc9f93ad27b2
--- /dev/null
+++ b/.rebuild
@@ -0,0 +1 @@
+1782552642
\ No newline at end of file
diff --git a/.restart_trigger b/.restart_trigger
new file mode 100644
index 0000000000000000000000000000000000000000..e580802946b9a7220d15085342b4e831279626c7
--- /dev/null
+++ b/.restart_trigger
@@ -0,0 +1 @@
+# restart trigger - v5.1 rewrite fix 2026-06-27
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bed2a391f57aae4c106228b98824a5a07c71913b..30dbc8acd7e303933080c6d983cfbafb2890b0e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,27 @@
-# FPT Play Stream Selector Update
+# VNEWS v5.1 - Rewrite Fix
-Added FPT Play channel with stream selector UI similar to VTV6:
+## Changes
-- 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
+### 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.
-## Changes
-- `static/vtv_init.js`: Added FPT tab + stream selector UI logic
-- `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data
+**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..1ba3320590f4c27d463fa54b0d2e19320add3655 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,47 +2,14 @@ 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
+CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
\ No newline at end of file
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..688ba54317c3df69d702269d4cee2edf60e7d621 100644
--- a/ai_ext.py
+++ b/ai_ext.py
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
from fastapi import Request, Query
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
-# Try to import main app, but don't fail if it doesn't exist
-try:
- from main import app
-except ImportError:
- # Create a minimal FastAPI app for standalone testing
- try:
- from fastapi import FastAPI
- app = FastAPI()
- except Exception:
- app = None
+from main import app
# Import wall store from main.py so we read/write the SAME file
try:
@@ -50,10 +41,6 @@ except ImportError:
def _web_context(topic):
return ""
-# ai_ext alias for backward compatibility
-_load_ai_wall = _load_wall
-_save_ai_wall = _save_wall
-
try:
from huggingface_hub import AsyncInferenceClient
except Exception:
@@ -79,254 +66,77 @@ def _hf_token():
return v
return ""
-
-def _clean_text(s: str) -> str:
- """Clean text for processing."""
- s = html_lib.unescape(s or "")
- s = re.sub(r"\s+", " ", s)
- return s.strip()
-
-
-def _domain(url: str) -> str:
- """Extract domain from URL."""
- try:
- return urlparse(url or "").netloc.replace("www.", "")
- except Exception:
- return ""
-
-
-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.
+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 =====
+MULTILINGUAL_VOICES = {
+ # Vietnamese
+ "vi_female": "vi-VN-HoaMyNeural",
+ "vi_male": "vi-VN-NamMinhNeural",
+ "hoaimy": "vi-VN-HoaMyNeural",
+ "namminh": "vi-VN-NamMinhNeural",
+ # Multilingual - Andrew (hỗ trợ tiếng Việt)
+ "en_andrew": "en-US-AndrewNeural",
+ "andrew": "en-US-AndrewNeural",
+ # Multilingual - Jenny
+ "en_jenny": "en-US-JennyNeural",
+ "jenny": "en-US-JennyNeural",
+ # Portuguese - Thalita Multilingual
+ "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
+ "thalita": "pt-BR-ThalitaMultilingualNeural",
+ "pt_francisco": "pt-BR-FranciscoNeural",
+ # Spanish
+ "es_ela": "es-ES-ElaNeural",
+ "ela": "es-ES-ElaNeural",
+ "es_carlos": "es-ES-CarlosNeural",
+ # French
+ "fr_denise": "fr-FR-DeniseNeural",
+ "denise": "fr-FR-DeniseNeural",
+ # German
+ "de_katja": "de-DE-KatjaNeural",
+ "katja": "de-DE-KatjaNeural",
+ # Japanese
+ "ja_nanami": "ja-JP-NanamiNeural",
+ "nanami": "ja-JP-NanamiNeural",
+ # Korean
+ "ko_sunhee": "ko-KR-SunHeeNeural",
+ "sunhee": "ko-KR-SunHeeNeural",
+ # Chinese
+ "zh_xiaochen": "zh-CN-XiaochenNeural",
+ "xiaochen": "zh-CN-XiaochenNeural",
+}
+
+def _detect_voice_emotion(title, text):
+ """Detect appropriate voice and emotion based on content for multilingual TTS."""
+ content = ((title or "") + " " + (text or "")).lower()
- Prioritizes Llama-3.3-70B for better creative/opinion writing.
- """
- token = _hf_token()
- errors = []
+ # 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") # Andrew multilingual hỗ trợ tiếng Việt
- # Try HF router API with multiple models - Llama FIRST for opinion writing
- 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-72B-Instruct",
- ]
- # Deduplicate while preserving order
- seen = set()
- models = [m for m in models if m and m not in seen and not seen.add(m)]
-
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
-
- for model in models:
- try:
- is_vl = "VL" in model and image_url
- if is_vl:
- user_content = [
- {"type": "image_url", "image_url": {"url": image_url}},
- {"type": "text", "text": prompt}
- ]
- else:
- user_content = prompt
-
- 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": "user", "content": user_content},
- ],
- "max_tokens": min(int(max_tokens or 2000), 2500),
- "temperature": 0.75,
- "top_p": 0.9,
- }
-
- r = requests.post(
- "https://router.huggingface.co/v1/chat/completions",
- headers=headers,
- json=payload,
- timeout=95
- )
-
- if r.status_code >= 300:
- errors.append(f"{model}: HTTP {r.status_code}")
- continue
-
- j = r.json()
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
-
- if txt:
- return txt
-
- errors.append(f"{model}: empty response")
-
- except Exception as e:
- errors.append(f"{model}: {type(e).__name__}")
+ # 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") # Portuguese multilingual
+ 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")
- # Fallback: extractive summary from prompt
- LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
- return _fallback_summary_from_prompt(prompt, max_units=6)
-
-
-def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
- """Generate a simple fallback summary when AI is unavailable."""
- text = prompt or ""
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
- if marker in text:
- text = text.split(marker, 1)[1]
- break
- text = re.sub(r"https?://\S+", "", text)
- text = re.sub(r"\s+", " ", text).strip()
-
- # Split into sentences
- sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
- units = []
- for s in sentences:
- s = _clean_text(s)
- if len(s) >= 30:
- units.append(s)
-
- if units:
- result_units = units[:max_units]
- return "\n".join("• " + u for u in result_units)
- if text:
- chunks = []
- for i in range(0, min(len(text), max_units * 300), 280):
- chunk = _clean_text(text[i:i+300])
- if chunk and chunk not in chunks:
- chunks.append(chunk)
- if len(chunks) >= max_units:
- break
- if chunks:
- 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)
-
-import random as _random2
-from datetime import datetime, timezone, timedelta
-_VN_TZ = timezone(timedelta(hours=7))
-
-
-def _safe_name(filename: str) -> str:
- """Sanitize filename."""
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
-
-
-def pollinations_image_url(topic: str) -> str:
- """Generate a placeholder image URL via Pollinations."""
- try:
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
- except Exception:
- return ""
-
-
-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
- 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
- except Exception:
- return out_path
-
-
-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": ""}
- 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"]):
- 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"]:
- 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)
- 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}
- except Exception as e:
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
-
-
-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"),
- }
+ # Default Vietnamese
+ return ("hoaimy", "trung_tinh")
diff --git a/ai_patch.py b/ai_patch.py
index 41aeba3d810744429c14e473c32c3a9fb8e3a605..838f49f293c523e5ecf7f003f5b424a2928e23ee 100644
--- a/ai_patch.py
+++ b/ai_patch.py
@@ -42,17 +42,17 @@ def _similar(a, b):
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
-def _dedupe_units(units, max_units=25):
- """Deduplicate units - only skip exact matches to ensure all bullet points are read."""
+def _dedupe_units(units, max_units=7):
out, seen = [], set()
for u in units:
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
if len(u) < 18:
continue
nu = _norm(u)
- # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
if nu in seen:
continue
+ if any(_similar(u, old) for old in out):
+ continue
seen.add(nu)
out.append(u)
if len(out) >= max_units:
@@ -60,7 +60,7 @@ def _dedupe_units(units, max_units=25):
return out
-def _postprocess_ai_text(text, max_units=20):
+def _postprocess_ai_text(text, max_units=7):
text = _clean(text)
if not text:
return text
@@ -79,9 +79,10 @@ def _postprocess_ai_text(text, max_units=20):
raw_lines.append(line)
units = []
for line in raw_lines:
- # KEEP FULL bullet point - don't truncate or split into segments
- if len(line) >= 18:
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
+ if len(line) > 260:
+ units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
+ else:
+ units.append(line)
units = _dedupe_units(units, max_units=max_units)
if not units:
return text[:900]
@@ -268,7 +269,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
errors.append("missing HF_TOKEN")
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
- return _fallback_summary_from_prompt(prompt, max_units=12)
+ return _fallback_summary_from_prompt(prompt, max_units=6)
if not hasattr(base, "_original_qwen_generate"):
@@ -321,30 +322,12 @@ Yêu cầu bắt buộc:
Nội dung bài:
{art['raw'][:14000]}"""
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
- text = _postprocess_ai_text(text, max_units=20)
+ text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
+ text = _postprocess_ai_text(text, max_units=6)
src = [art['source']]
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)
@@ -365,142 +348,14 @@ async def compat_url_wall(request: Request):
if len(raw) < 120:
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
- text = _postprocess_ai_text(text, max_units=20)
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
+ text = _postprocess_ai_text(text, max_units=6)
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
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})
-
-
-def _is_relevant_image(img_url, title, text):
- """Check if an image is relevant to the article content."""
- if not img_url:
- return False
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
- img_lower = img_url.lower()
- for p in skip_patterns:
- if p in img_lower:
- return False
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
- return False
- return True
-
-
-def _filter_relevant_images(images, title, text, max_images=8):
- """Filter and rank images by relevance to article content."""
- if not images:
- return []
- seen = set()
- relevant = []
- for img in images:
- if img in seen:
- continue
- seen.add(img)
- if _is_relevant_image(img, title, text):
- relevant.append(img)
- return relevant[:max_images]
-
-
-def _extract_key_points_for_slides(paragraphs, max_points=12):
- """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
- points = []
- for p in paragraphs:
- if len(points) >= max_points:
- break
- p = _clean(p)
- if not p:
- continue
- # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
- sentences = [s.strip() for s in sentences if s.strip()]
-
- for sentence in sentences:
- if len(points) >= max_points:
- break
- sentence = _clean(sentence)
- if len(sentence) < 30:
- continue
- if any(sentence[:60] in existing for existing in points):
- continue
- if not sentence.endswith(('.', '!', '?')):
- sentence = sentence + '.'
- points.append(sentence)
- return points
-
-
-def _scrape_article_images(url):
- """Scrape article page and return only relevant images."""
- try:
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
- r.encoding = 'utf-8'
- soup = BeautifulSoup(r.text, 'lxml')
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
- tag.decompose()
- 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 '')
- ogi = soup.find('meta', property='og:image')
- og_img = ogi.get('content', '') if ogi else ''
- if og_img and og_img.startswith('//'):
- og_img = 'https:' + og_img
- 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:
- block = el
- break
- if not block:
- block = soup.body or soup
- paragraphs = []
- all_images = []
- seen_imgs = set()
- if og_img and og_img not in seen_imgs:
- all_images.append(og_img)
- seen_imgs.add(og_img)
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
- if el.name == 'p':
- t = _clean(el.get_text(strip=True))
- if t and len(t) > 40:
- paragraphs.append(t)
- elif el.name in ('figure', 'img'):
- 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
- if src not in seen_imgs:
- all_images.append(src)
- seen_imgs.add(src)
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
- except Exception:
- return None
+ return JSONResponse({'post': post})
@app.post('/api/rewrite_share')
@@ -517,42 +372,31 @@ async def compat_rewrite_share(request: Request):
if len(raw) < 120:
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
- text = _postprocess_ai_text(text, max_units=20)
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
+ text = _postprocess_ai_text(text, max_units=6)
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
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)
-
- # Generate slides with relevant images only
- slides = []
- 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})
-
- # 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})
+ return JSONResponse({'post': post})
def _emotion_script(text, emotion):
- """Prepend emotion-appropriate prefix to text based on emotion type.
-
- NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
- The emotion is still used for voice selection but content is read cleanly.
- """
+ """Prepend emotion-appropriate prefix to text based on emotion type."""
text = _clean(text)
- # REMOVED: No prefix added to keep content clean and natural
- return text
+ prefixes = {
+ '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. ',
+ 'happy': 'Tin vui! ',
+ 'sad': 'Tin buồn. ',
+ 'humorous': 'Chút hài hước. ',
+ 'neutral': '',
+ }
+ prefix = prefixes.get(emotion, '')
+ return prefix + text
def _tts_script_smart(post, emotion):
@@ -561,12 +405,11 @@ def _tts_script_smart(post, emotion):
raw = re.sub(r"\s*\n\s*", ". ", raw)
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
raw = re.sub(r"\n{2,}", "\n", raw).strip()
- # REMOVED: _emotion_script call - read content cleanly without prefix
- # INCREASED to 3000 to read full content of all bullet points
- if len(raw) > 3000:
- raw = raw[:3000]
+ raw = _emotion_script(raw, emotion)
+ if len(raw) > 1000:
+ raw = raw[:1000]
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
- if cut > 700:
+ if cut > 350:
raw = raw[:cut + 1]
return raw
@@ -681,7 +524,7 @@ def _make_short_frame_full(post, img_path, out_path):
-def _summary_segments_from_post(post, max_segments=25):
+def _summary_segments_from_post(post, max_segments=7):
raw = _clean(post.get('text') or post.get('title') or '')
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
@@ -692,7 +535,7 @@ def _summary_segments_from_post(post, max_segments=25):
low=ln.lower()
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
if len(ln)>=18: lines.append(ln)
- if len(lines)<3:
+ if len(lines)<2:
lines=[]
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
s=_clean(s)
@@ -744,8 +587,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
y=940; maxw=W-96
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
+ for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
y+=74
if y>1500: break
@@ -757,11 +599,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
bg.save(out_path, quality=92)
-def _estimate_audio_duration(path, fallback=15.0):
- """Estimate audio duration with 15s minimum per segment for complete bullet reading."""
+def _estimate_audio_duration(path, fallback=4.0):
try:
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
- return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
+ return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
except Exception:
return fallback
@@ -774,7 +615,7 @@ async def patched_ai_short(post_id: str, request: Request):
body = {}
voice = str(body.get('voice', 'nu')).strip().lower()
emotion = str(body.get('emotion', 'neutral')).strip().lower()
- speed = float(body.get('speed', 1.0) or 1.0)
+ speed = float(body.get('speed', 1.2) or 1.2)
speed = max(0.85, min(1.35, speed))
posts = base._load_ai_wall()
@@ -782,7 +623,7 @@ async def patched_ai_short(post_id: str, request: Request):
if not post:
return JSONResponse({'error': 'post not found'}, status_code=404)
- segments = _summary_segments_from_post(post, max_segments=25)
+ segments = _summary_segments_from_post(post, max_segments=7)
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
os.makedirs(base.SHORTS_DIR, exist_ok=True)
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
@@ -806,62 +647,50 @@ async def patched_ai_short(post_id: str, request: Request):
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
edge_voice = {
# Vietnamese
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
- 'hoaimy': 'vi-VN-HoaiMyNeural',
- 'namminh': 'vi-VN-NamMinhNeural',
'nam': 'vi-VN-NamMinhNeural',
'male': 'vi-VN-NamMinhNeural',
'nu': 'vi-VN-HoaiMyNeural',
'female': 'vi-VN-HoaiMyNeural',
'mien-nam': 'vi-VN-HoaiMyNeural',
- # English - Multilingual
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
- 'andrew': 'en-US-AndrewMultilingualNeural',
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
- 'jenny': 'en-US-AndrewMultilingualNeural',
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
- # Portuguese - Multilingual (ONLY Thalita)
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
+ 'namminh': 'vi-VN-NamMinhNeural',
+ # Multilingual - Andrew
+ 'andrew': 'en-US-AndrewNeural',
+ 'en_andrew': 'en-US-AndrewNeural',
+ # Multilingual - Jenny
+ 'jenny': 'en-US-JennyNeural',
+ 'en_jenny': 'en-US-JennyNeural',
+ # Portuguese - Thalita Multilingual
'thalita': 'pt-BR-ThalitaMultilingualNeural',
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
- # French - Multilingual
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
- 'denise': 'fr-FR-VivienneMultilingualNeural',
- 'fr': 'fr-FR-VivienneMultilingualNeural',
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
- # German - Multilingual
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
- 'de': 'de-DE-SeraphinaMultilingualNeural',
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
- # Korean - Multilingual (Hyunsu, NOT SunHee)
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
- # Italian - Multilingual
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
- # Spanish (keep for backward compat)
- 'ela': 'en-US-AndrewMultilingualNeural',
- 'es_ela': 'en-US-AndrewMultilingualNeural',
- 'es': 'en-US-AndrewMultilingualNeural',
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
- # Japanese (keep for backward compat)
- 'nanami': 'en-US-AndrewMultilingualNeural',
- 'ja': 'en-US-AndrewMultilingualNeural',
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
- # Chinese (keep for backward compat)
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
- 'zh': 'en-US-AndrewMultilingualNeural',
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
+ 'pt': 'pt-BR-FranciscoNeural',
+ 'pt_francisco': 'pt-BR-FranciscoNeural',
+ # Spanish
+ 'ela': 'es-ES-ElaNeural',
+ 'es_ela': 'es-ES-ElaNeural',
+ 'es': 'es-ES-CarlosNeural',
+ 'es_carlos': 'es-ES-CarlosNeural',
+ # French
+ 'denise': 'fr-FR-DeniseNeural',
+ 'fr': 'fr-FR-DeniseNeural',
+ 'fr_denise': 'fr-FR-DeniseNeural',
+ # German
+ 'katja': 'de-DE-KatjaNeural',
+ 'de': 'de-DE-KatjaNeural',
+ 'de_katja': 'de-DE-KatjaNeural',
+ # Japanese
+ 'nanami': 'ja-JP-NanamiNeural',
+ 'ja': 'ja-JP-NanamiNeural',
+ 'ja_nanami': 'ja-JP-NanamiNeural',
+ # Korean
+ 'sunhee': 'ko-KR-SunHeeNeural',
+ 'ko': 'ko-KR-SunHeeNeural',
+ 'ko_sunhee': 'ko-KR-SunHeeNeural',
+ # Chinese
+ 'xiaochen': 'zh-CN-XiaochenNeural',
+ 'zh': 'zh-CN-XiaochenNeural',
+ 'zh_xiaochen': 'zh-CN-XiaochenNeural',
}.get(voice, 'vi-VN-HoaiMyNeural')
part_files=[]
for idx, seg in enumerate(segments):
@@ -880,7 +709,7 @@ async def patched_ai_short(post_id: str, request: Request):
except TypeError:
base.gTTS(spoken, lang='vi', slow=False).save(aud)
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
+ dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
part_files.append(part)
concat=os.path.join(work,'concat.txt')
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 '