Spaces:
Running
Running
Fix Tạo Ảnh Bài Viết + AI wall: sort newest-first, drop empty posts, xem trc text on bg, longer AI text, popular bg styles
Browse files- /api/wall + /api/ai_wall: normalize ms/s timestamps, sort desc, drop posts with no content / placeholder images (non-destructive, view-only)
- post_image_designer.js: numeric pos in _pidDrawTextWrapped + scrim band so 'xem trc' text shows on bg image; wire preview/save buttons; style chips for SenseNova AI bg; num_lines 5->8
- pid_gen.py: _local_gen_text returns 9-11 sentence Vietnamese article; acronym-safe; gen_text default num_lines 8
- index_v2.html: cache-bust post_image_designer.js?v=20260906pidfix
- ai_ext.py +148 -0
- ai_patch.py +5 -1
- ai_runtime_patch_fast.py +9 -1
- app_v2_entry.py +46 -16
- pid_gen.py +25 -15
- static/index_v2.html +1 -1
- static/post_image_designer.js +75 -5
ai_ext.py
CHANGED
|
@@ -54,6 +54,154 @@ except ImportError:
|
|
| 54 |
_load_ai_wall = _load_wall
|
| 55 |
_save_ai_wall = _save_wall
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
| 59 |
except Exception:
|
|
|
|
| 54 |
_load_ai_wall = _load_wall
|
| 55 |
_save_ai_wall = _save_wall
|
| 56 |
|
| 57 |
+
|
| 58 |
+
# =========================================================================
|
| 59 |
+
# Shared Tường AI wall view logic (sort + filter)
|
| 60 |
+
# Used by /api/wall and /api/ai_wall so every consumer sees the SAME
|
| 61 |
+
# newest-first, content-filtered feed.
|
| 62 |
+
# =========================================================================
|
| 63 |
+
_PLACEHOLDER_MARKERS = (
|
| 64 |
+
"pollinations.ai", # AI-generated editorial illustration placeholder
|
| 65 |
+
"logo_default.jpg", # vnexpress placeholder logo
|
| 66 |
+
"logo.png",
|
| 67 |
+
"/logo",
|
| 68 |
+
"placeholder",
|
| 69 |
+
"no-image",
|
| 70 |
+
"blank",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def _is_placeholder_url(url):
|
| 74 |
+
if not url or not isinstance(url, str):
|
| 75 |
+
return True
|
| 76 |
+
u = url.strip().lower()
|
| 77 |
+
if not u or u.startswith(("about:blank", "data:")):
|
| 78 |
+
return True
|
| 79 |
+
return any(m in u for m in _PLACEHOLDER_MARKERS)
|
| 80 |
+
|
| 81 |
+
def _is_real_url(url):
|
| 82 |
+
return bool(url) and not _is_placeholder_url(url)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _wall_created_ts(p):
|
| 86 |
+
"""Best-effort creation timestamp, NORMALIZED TO SECONDS epoch.
|
| 87 |
+
`created` is stored as Unix milliseconds (e.g. 1788444727727) by some
|
| 88 |
+
paths while `ts` is Unix seconds (e.g. 1788696117) — mixing them without
|
| 89 |
+
normalization breaks newest-first ordering. Falls back to id-derived Unix
|
| 90 |
+
ms (converted to seconds), then 0. Never crashes."""
|
| 91 |
+
if not isinstance(p, dict):
|
| 92 |
+
return 0
|
| 93 |
+
for field in ("created", "ts"):
|
| 94 |
+
v = p.get(field)
|
| 95 |
+
if v is not None:
|
| 96 |
+
try:
|
| 97 |
+
v = int(v)
|
| 98 |
+
except (ValueError, TypeError):
|
| 99 |
+
continue
|
| 100 |
+
if v > 100000000000: # milliseconds -> seconds
|
| 101 |
+
v = v // 1000
|
| 102 |
+
return v
|
| 103 |
+
pid = str(p.get("id", "")).strip()
|
| 104 |
+
if pid.isdigit() and len(pid) >= 12:
|
| 105 |
+
try:
|
| 106 |
+
v = int(pid)
|
| 107 |
+
if v > 100000000000:
|
| 108 |
+
v = v // 1000
|
| 109 |
+
return v
|
| 110 |
+
except (ValueError, TypeError):
|
| 111 |
+
pass
|
| 112 |
+
return 0
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _wall_post_has_valid_image(p):
|
| 116 |
+
"""True when the post has at least one REAL (non-placeholder) image:
|
| 117 |
+
p.img, p.short_thumb, p.images[], p.slides[].image."""
|
| 118 |
+
if _is_real_url(p.get("img")):
|
| 119 |
+
return True
|
| 120 |
+
if _is_real_url(p.get("short_thumb")):
|
| 121 |
+
return True
|
| 122 |
+
images = p.get("images") or []
|
| 123 |
+
if any(_is_real_url(x) for x in images):
|
| 124 |
+
return True
|
| 125 |
+
slides = p.get("slides") or []
|
| 126 |
+
if any(_is_real_url((s or {}).get("image")) for s in slides if isinstance(s, dict)):
|
| 127 |
+
return True
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _wall_post_has_content(p):
|
| 132 |
+
"""True when the post has meaningful content: non-empty text (>= 20 chars)
|
| 133 |
+
OR at least one slide with a non-empty text. Posts with empty/whitespace
|
| 134 |
+
text, empty slide lists, or slides that are all blank are removed."""
|
| 135 |
+
if not isinstance(p, dict):
|
| 136 |
+
return False
|
| 137 |
+
text = (p.get("text") or "").strip()
|
| 138 |
+
if len(text) >= 20:
|
| 139 |
+
return True
|
| 140 |
+
slides = p.get("slides") or []
|
| 141 |
+
if len(slides) >= 1:
|
| 142 |
+
for s in slides:
|
| 143 |
+
if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8:
|
| 144 |
+
return True
|
| 145 |
+
# Video-only posts (e.g. FPT wall videos / user uploads) have content via video
|
| 146 |
+
if (p.get("video") or "").strip():
|
| 147 |
+
return True
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _wall_post_has_slide_info(p):
|
| 152 |
+
"""True if the post has usable slide/rewrite AI content:
|
| 153 |
+
- designed slides with text+image, OR
|
| 154 |
+
- a generated short video (/api/ai/short-file/) with a real thumbnail, OR
|
| 155 |
+
- an oEmbed iframe (YouTube/TikTok/FB) with a valid image."""
|
| 156 |
+
slides = p.get("slides") or []
|
| 157 |
+
if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)):
|
| 158 |
+
return True
|
| 159 |
+
vid = (p.get("video") or "").strip()
|
| 160 |
+
if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip():
|
| 161 |
+
return True
|
| 162 |
+
if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or
|
| 163 |
+
"facebook.com/plugins" in vid or "instagram.com" in vid or
|
| 164 |
+
"twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid):
|
| 165 |
+
if _wall_post_has_valid_image(p):
|
| 166 |
+
return True
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _wall_is_auto_post(p):
|
| 171 |
+
kind = (p.get("kind") or "").strip().lower()
|
| 172 |
+
source = (p.get("source") or "").strip().lower()
|
| 173 |
+
return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
|
| 174 |
+
source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def wall_posts_for_view(posts=None, limit=200):
|
| 178 |
+
"""Unified Tường AI wall view:
|
| 179 |
+
1) drop posts with NO content at all (empty text AND empty slides
|
| 180 |
+
AND no video) — the 'bài đăng rewrite fără nội dung';
|
| 181 |
+
2) drop auto-generated posts that BOTH have a placeholder/missing image
|
| 182 |
+
(pollinations.ai, vnexpress logo, empty) AND no slide-AI content —
|
| 183 |
+
these are the truly broken posts that show the wrong thumbnail;
|
| 184 |
+
3) sort newest-first mixing all sources;
|
| 185 |
+
4) cap at `limit` (default 200).
|
| 186 |
+
Non-destructive: never touches the persistent store."""
|
| 187 |
+
if posts is None:
|
| 188 |
+
posts = _load_ai_wall()
|
| 189 |
+
if not isinstance(posts, list):
|
| 190 |
+
return []
|
| 191 |
+
out = []
|
| 192 |
+
for p in posts:
|
| 193 |
+
if not isinstance(p, dict):
|
| 194 |
+
continue
|
| 195 |
+
if not _wall_post_has_content(p):
|
| 196 |
+
continue
|
| 197 |
+
if _wall_is_auto_post(p) and not _wall_post_has_valid_image(p) and not _wall_post_has_slide_info(p):
|
| 198 |
+
# auto post with a wrong/placeholder image AND no slide AI content
|
| 199 |
+
# -> hide from the wall (non-destructive)
|
| 200 |
+
continue
|
| 201 |
+
out.append(p)
|
| 202 |
+
out.sort(key=_wall_created_ts, reverse=True)
|
| 203 |
+
return out[:limit]
|
| 204 |
+
|
| 205 |
try:
|
| 206 |
from huggingface_hub import AsyncInferenceClient
|
| 207 |
except Exception:
|
ai_patch.py
CHANGED
|
@@ -278,7 +278,11 @@ base.qwen_generate = qwen_generate_resilient
|
|
| 278 |
|
| 279 |
@app.get('/api/wall')
|
| 280 |
def compat_wall():
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
|
| 284 |
_PATCHED_PATHS = {
|
|
|
|
| 278 |
|
| 279 |
@app.get('/api/wall')
|
| 280 |
def compat_wall():
|
| 281 |
+
try:
|
| 282 |
+
from ai_ext import wall_posts_for_view
|
| 283 |
+
return JSONResponse({'posts': wall_posts_for_view(limit=200)})
|
| 284 |
+
except Exception:
|
| 285 |
+
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 286 |
|
| 287 |
|
| 288 |
_PATCHED_PATHS = {
|
ai_runtime_patch_fast.py
CHANGED
|
@@ -56,7 +56,15 @@ def _sh(refresh:int=Query(default=0)):
|
|
| 56 |
if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
|
| 57 |
return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
|
| 58 |
@app.get('/api/ai_wall')
|
| 59 |
-
def _w():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
@app.get('/api/storage_status')
|
| 61 |
def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
|
| 62 |
@app.get('/api/short/comments')
|
|
|
|
| 56 |
if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
|
| 57 |
return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
|
| 58 |
@app.get('/api/ai_wall')
|
| 59 |
+
def _w():
|
| 60 |
+
n=int(time.time())
|
| 61 |
+
try:
|
| 62 |
+
from ai_ext import wall_posts_for_view, _wall_created_ts
|
| 63 |
+
posts=wall_posts_for_view(limit=200)
|
| 64 |
+
posts=[p for p in posts if n-_wall_created_ts(p)<TTL_24H]
|
| 65 |
+
except Exception:
|
| 66 |
+
posts=[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H]
|
| 67 |
+
return JSONResponse({'posts':posts,'persistent':HAS_PERSISTENT})
|
| 68 |
@app.get('/api/storage_status')
|
| 69 |
def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
|
| 70 |
@app.get('/api/short/comments')
|
app_v2_entry.py
CHANGED
|
@@ -992,37 +992,65 @@ def _is_auto_post(p):
|
|
| 992 |
return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
|
| 993 |
source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
|
| 994 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
def _created_ts(p):
|
| 996 |
-
"""Best-effort creation timestamp
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
to
|
| 1000 |
for field in ("created", "ts"):
|
| 1001 |
v = p.get(field)
|
| 1002 |
if v is not None:
|
| 1003 |
try:
|
| 1004 |
-
|
| 1005 |
except (ValueError, TypeError):
|
| 1006 |
pass
|
|
|
|
|
|
|
|
|
|
| 1007 |
# try to derive from id (numeric prefix = Unix ms timestamp)
|
| 1008 |
pid = str(p.get("id", "")).strip()
|
| 1009 |
if pid.isdigit() and len(pid) >= 12:
|
| 1010 |
try:
|
| 1011 |
-
|
|
|
|
|
|
|
|
|
|
| 1012 |
except (ValueError, TypeError):
|
| 1013 |
pass
|
| 1014 |
return 0
|
| 1015 |
|
| 1016 |
def _wall_posts_for_view():
|
| 1017 |
-
"""Apply the user-facing wall filtering + sorting
|
| 1018 |
-
|
|
|
|
|
|
|
|
|
|
| 1019 |
valid image at all — pollinations.ai, vnexpress logo, empty) or have
|
| 1020 |
-
no slide-AI content (no slides / no short-video / no embed)
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
|
|
|
| 1026 |
posts = _load_wall_posts()
|
| 1027 |
if not posts:
|
| 1028 |
return []
|
|
@@ -1030,8 +1058,10 @@ def _wall_posts_for_view():
|
|
| 1030 |
for p in posts:
|
| 1031 |
if not isinstance(p, dict):
|
| 1032 |
continue
|
| 1033 |
-
if
|
| 1034 |
-
|
|
|
|
|
|
|
| 1035 |
# content -> hide from the wall (non-destructive)
|
| 1036 |
continue
|
| 1037 |
filtered.append(p)
|
|
|
|
| 992 |
return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
|
| 993 |
source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
|
| 994 |
|
| 995 |
+
def _post_has_content(p):
|
| 996 |
+
"""True when the post has meaningful content: non-empty text (>= 20 chars)
|
| 997 |
+
OR at least one slide with non-empty text OR a video. Rewrite posts that
|
| 998 |
+
failed to generate content (empty text+slides) get dropped from the feed."""
|
| 999 |
+
if not isinstance(p, dict):
|
| 1000 |
+
return False
|
| 1001 |
+
text = (p.get("text") or "").strip()
|
| 1002 |
+
if len(text) >= 20:
|
| 1003 |
+
return True
|
| 1004 |
+
slides = p.get("slides") or []
|
| 1005 |
+
if len(slides) >= 1:
|
| 1006 |
+
for s in slides:
|
| 1007 |
+
if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8:
|
| 1008 |
+
return True
|
| 1009 |
+
if (p.get("video") or "").strip():
|
| 1010 |
+
return True
|
| 1011 |
+
return False
|
| 1012 |
+
|
| 1013 |
def _created_ts(p):
|
| 1014 |
+
"""Best-effort creation timestamp NORMALIZED TO SECONDS. `created` is
|
| 1015 |
+
stored as Unix milliseconds by some paths while `ts` is Unix seconds —
|
| 1016 |
+
normalize both so newest-first ordering mixes them correctly. Falls back
|
| 1017 |
+
to id-derived Unix ms (converted to seconds), then 0."""
|
| 1018 |
for field in ("created", "ts"):
|
| 1019 |
v = p.get(field)
|
| 1020 |
if v is not None:
|
| 1021 |
try:
|
| 1022 |
+
v = int(v)
|
| 1023 |
except (ValueError, TypeError):
|
| 1024 |
pass
|
| 1025 |
+
if v > 100000000000: # milliseconds -> seconds
|
| 1026 |
+
v = v // 1000
|
| 1027 |
+
return v
|
| 1028 |
# try to derive from id (numeric prefix = Unix ms timestamp)
|
| 1029 |
pid = str(p.get("id", "")).strip()
|
| 1030 |
if pid.isdigit() and len(pid) >= 12:
|
| 1031 |
try:
|
| 1032 |
+
v = int(pid)
|
| 1033 |
+
if v > 100000000000:
|
| 1034 |
+
v = v // 1000
|
| 1035 |
+
return v
|
| 1036 |
except (ValueError, TypeError):
|
| 1037 |
pass
|
| 1038 |
return 0
|
| 1039 |
|
| 1040 |
def _wall_posts_for_view():
|
| 1041 |
+
"""Apply the user-facing wall filtering + sorting (VIEW-ONLY — never
|
| 1042 |
+
touches the persistent store):
|
| 1043 |
+
1) Drop posts with NO meaningful content (empty text AND empty slides
|
| 1044 |
+
AND no video) — 'bài đăng rewrite fără nội dung';
|
| 1045 |
+
2) Hide auto-generated posts that have a wrong/placeholder image (no
|
| 1046 |
valid image at all — pollinations.ai, vnexpress logo, empty) or have
|
| 1047 |
+
no slide-AI content (no slides / no short-video / no embed);
|
| 1048 |
+
3) Sort newest-first by creation timestamp, mixing all sources."""
|
| 1049 |
+
try:
|
| 1050 |
+
from ai_ext import wall_posts_for_view as _shared_view
|
| 1051 |
+
return _shared_view(limit=200)
|
| 1052 |
+
except Exception:
|
| 1053 |
+
pass
|
| 1054 |
posts = _load_wall_posts()
|
| 1055 |
if not posts:
|
| 1056 |
return []
|
|
|
|
| 1058 |
for p in posts:
|
| 1059 |
if not isinstance(p, dict):
|
| 1060 |
continue
|
| 1061 |
+
if not _post_has_content(p):
|
| 1062 |
+
continue
|
| 1063 |
+
if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p):
|
| 1064 |
+
# auto post with a wrong/placeholder/missing image AND no slide AI
|
| 1065 |
# content -> hide from the wall (non-destructive)
|
| 1066 |
continue
|
| 1067 |
filtered.append(p)
|
pid_gen.py
CHANGED
|
@@ -202,35 +202,45 @@ def _kb_line(topic):
|
|
| 202 |
|
| 203 |
|
| 204 |
def _local_gen_text(topic, n):
|
| 205 |
-
"""Full Vietnamese news-article prose (
|
| 206 |
-
|
|
|
|
|
|
|
| 207 |
frags = []
|
| 208 |
for x in news:
|
| 209 |
t = re.sub(r"<[^>]+>", "", x.get("title") or "")
|
| 210 |
t = re.sub(r"\s+", " ", t).strip()
|
| 211 |
t = re.sub(r"\s*-\s*[^\n\r]{0,30}$", "", t)
|
| 212 |
-
t = t[:
|
| 213 |
if t and len(t) >= 15 and t not in frags:
|
| 214 |
frags.append(t)
|
| 215 |
tl = (topic or "").strip().lower()
|
| 216 |
kb = _kb_line(tl)
|
| 217 |
t2 = re.sub(r"[\s\-:]+", " ", topic)[:45] or "vấn đề này"
|
|
|
|
| 218 |
parts = []
|
| 219 |
if kb:
|
| 220 |
parts.append(kb)
|
| 221 |
-
|
| 222 |
-
t2c = t2[0].upper() + t2[1:] if t2 else "Chủ đề này"
|
| 223 |
-
parts.append(f"{t2c} là một chủ đề đang nhận được sự quan tâm rất lớn trong thời gian gần đây.")
|
| 224 |
if frags:
|
| 225 |
-
parts.append(f"
|
| 226 |
else:
|
| 227 |
-
parts.append(f"
|
| 228 |
-
for f in frags[:
|
| 229 |
-
if not f:
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
article = " ".join(p for p in parts if p)
|
| 235 |
return [article]
|
| 236 |
|
|
@@ -293,7 +303,7 @@ def _register(app):
|
|
| 293 |
@app.post('/api/pid/gen_text')
|
| 294 |
def gen_text(req: GenTextReq):
|
| 295 |
try:
|
| 296 |
-
n = max(
|
| 297 |
topic = (req.topic or '').strip()
|
| 298 |
if not topic:
|
| 299 |
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
|
|
|
| 202 |
|
| 203 |
|
| 204 |
def _local_gen_text(topic, n):
|
| 205 |
+
"""Full Vietnamese news-article prose (9-11 sentences) — richer output so
|
| 206 |
+
the "Tạo nội đúng AI" feature never returns a 1-3 line stub. Always yields
|
| 207 |
+
at least `n` usable lines."""
|
| 208 |
+
news = _fetch_news(topic, limit=10)
|
| 209 |
frags = []
|
| 210 |
for x in news:
|
| 211 |
t = re.sub(r"<[^>]+>", "", x.get("title") or "")
|
| 212 |
t = re.sub(r"\s+", " ", t).strip()
|
| 213 |
t = re.sub(r"\s*-\s*[^\n\r]{0,30}$", "", t)
|
| 214 |
+
t = t[:120]
|
| 215 |
if t and len(t) >= 15 and t not in frags:
|
| 216 |
frags.append(t)
|
| 217 |
tl = (topic or "").strip().lower()
|
| 218 |
kb = _kb_line(tl)
|
| 219 |
t2 = re.sub(r"[\s\-:]+", " ", topic)[:45] or "vấn đề này"
|
| 220 |
+
t2c = t2[0].upper() + t2[1:] if t2 else "Chủ đề này"
|
| 221 |
parts = []
|
| 222 |
if kb:
|
| 223 |
parts.append(kb)
|
| 224 |
+
parts.append(f"{t2c} đang thu hút sự quan tâm rất lớn của dư luận trong thời gian gần đây, khi những diễn biến xoay quanh nó liên tục được cập nhật trên các mặt báo lớn.")
|
|
|
|
|
|
|
| 225 |
if frags:
|
| 226 |
+
parts.append(f"Theo ghi nhận, thông tin mới nhất cho thấy {t2} đang được nhiều chuyên gia đánh giá là có tác động sâu rộng đến đời sống kinh tế - xã hội.")
|
| 227 |
else:
|
| 228 |
+
parts.append(f"Theo ghi nhận, {t2} đang được nhiều chuyên gia đánh giá là vấn đề có tác động sâu rộng và cần được nghiên cứu kỹ lưỡng.")
|
| 229 |
+
for f in frags[:5]:
|
| 230 |
+
if not f:
|
| 231 |
+
continue
|
| 232 |
+
# keep acronyms/initials intact: only lower-case the first letter when
|
| 233 |
+
# the word starts with a capital letter AND is followed by lowercase
|
| 234 |
+
# letters (e.g. 'Thắng' -> 'thắng'); leave 'MU', 'HLV', 'FPT' as-is
|
| 235 |
+
fc = f
|
| 236 |
+
if f and f[0].isupper() and (len(f) < 2 or not f[1].isupper()):
|
| 237 |
+
fc = f[0].lower() + f[1:]
|
| 238 |
+
parts.append(f"{fc} — một diễn biến mới được báo cáo gần đây, tiếp tục hé lộ nhiều thông tin quan trọng liên quan đến chủ đề này.")
|
| 239 |
+
parts.append(f"Không chỉ dừng lại ở phạm vi một nhóm đối tượng, sự việc còn kéo theo những hệ lụy lan tỏa, buộc các bên liên quan phải có sự chuẩn bị kỹ lưỡng.")
|
| 240 |
+
parts.append(f"Các chuyên gia cũng khuyến nghị người dân nên tham khảo nhiều nguồn tin khác nhau, tránh bị chi phối bởi những thông tin chưa được kiểm chứng.")
|
| 241 |
+
parts.append(f"Trong bối cảnh đó, việc theo dõi sát sao và cập nhật thông tin thường xuyên là điều cần thiết để có cái nhìn toàn diện và chính xác nhất.")
|
| 242 |
+
parts.append(f"Nhìn chung, những diễn biến xoay quanh {t2} vẫn đang tiếp tục phát triển, hứa hẹn sẽ mang đến nhiều điều bất ngờ trong thời gian tới.")
|
| 243 |
+
parts.append(f"Với tầm quan trọng và sức ảnh hưởng của mình, {t2} chắc chắn sẽ còn được dư luận quan tâm theo dõi trong nhiều ngày nữa.")
|
| 244 |
article = " ".join(p for p in parts if p)
|
| 245 |
return [article]
|
| 246 |
|
|
|
|
| 303 |
@app.post('/api/pid/gen_text')
|
| 304 |
def gen_text(req: GenTextReq):
|
| 305 |
try:
|
| 306 |
+
n = max(8, min(int(req.num_lines or 8), 12))
|
| 307 |
topic = (req.topic or '').strip()
|
| 308 |
if not topic:
|
| 309 |
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
static/index_v2.html
CHANGED
|
@@ -89,7 +89,7 @@ var SPACE=location.origin;
|
|
| 89 |
</script>
|
| 90 |
<script src="/static/app_v2.js?v=20260903livefootball"></script>
|
| 91 |
<script src="/static/designer_v2.js?v=20260817fix1"></script>
|
| 92 |
-
<script src="/static/post_image_designer.js?v=
|
| 93 |
<script src="/static/yt_live.js"></script>
|
| 94 |
<script src="/static/vtv_init.js"></script>
|
| 95 |
<script src="/static/hot_multi.js?v=1"></script>
|
|
|
|
| 89 |
</script>
|
| 90 |
<script src="/static/app_v2.js?v=20260903livefootball"></script>
|
| 91 |
<script src="/static/designer_v2.js?v=20260817fix1"></script>
|
| 92 |
+
<script src="/static/post_image_designer.js?v=20260906pidfix"></script>
|
| 93 |
<script src="/static/yt_live.js"></script>
|
| 94 |
<script src="/static/vtv_init.js"></script>
|
| 95 |
<script src="/static/hot_multi.js?v=1"></script>
|
static/post_image_designer.js
CHANGED
|
@@ -289,7 +289,11 @@ function _pidDrawText(ctx, W, H, opts){
|
|
| 289 |
}
|
| 290 |
}
|
| 291 |
}
|
| 292 |
-
/* draw text wrapped into <= maxLines, within a vertical band
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
function _pidDrawTextWrapped(ctx, W, H, text, pos, color, glow, baseScale, maxLines){
|
| 294 |
if(!text || !text.trim()) return 0;
|
| 295 |
var fs = Math.max(24, Math.round(W * baseScale));
|
|
@@ -308,9 +312,27 @@ function _pidDrawTextWrapped(ctx, W, H, text, pos, color, glow, baseScale, maxLi
|
|
| 308 |
}
|
| 309 |
var lh = Math.round(fs0 * 1.22);
|
| 310 |
var blockH = Math.min(lines.length, maxLines) * lh;
|
| 311 |
-
var bandTop
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
var y = bandTop + Math.round((bandH - blockH) / 2) + lh / 2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
if(glow === 'white'){ ctx.shadowColor = 'rgba(255,255,255,0.95)'; ctx.shadowBlur = Math.max(6, fs0 * 0.45); }
|
| 315 |
else if(glow === 'color'){ ctx.shadowColor = color; ctx.shadowBlur = Math.max(8, fs0 * 0.55); }
|
| 316 |
else if(glow === 'neon'){ ctx.shadowColor = '#00ffff'; ctx.shadowBlur = Math.max(12, fs0 * 0.75); }
|
|
@@ -319,7 +341,7 @@ function _pidDrawTextWrapped(ctx, W, H, text, pos, color, glow, baseScale, maxLi
|
|
| 319 |
var drawn = 0;
|
| 320 |
lines.forEach(function(l){
|
| 321 |
if(drawn >= maxLines) return;
|
| 322 |
-
if(
|
| 323 |
ctx.fillText(l, W / 2, y); y += lh; drawn++;
|
| 324 |
});
|
| 325 |
ctx.shadowBlur = 0;
|
|
@@ -407,7 +429,7 @@ function _pidAIGenText(){
|
|
| 407 |
fetch('/api/pid/gen_text', {
|
| 408 |
method: 'POST',
|
| 409 |
headers: {'Content-Type': 'application/json'},
|
| 410 |
-
body: JSON.stringify({topic: topic, model: model, num_lines:
|
| 411 |
}).then(function(r){
|
| 412 |
if(!r.ok) return r.json().then(function(j){ throw new Error(j.error || 'Lỗi tạo text'); });
|
| 413 |
return r.json();
|
|
@@ -693,6 +715,11 @@ function _pidBuildDesigner(){
|
|
| 693 |
h += '<div style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">✨ Tạo ảnh nền bằng AI</div>';
|
| 694 |
h += '<div style="display:flex;gap:6px;margin-bottom:6px"><input id="pid-ai-prompt" placeholder="Mô tả ảnh nền (VD: sân vận động đêm, cờ đỏ sao vàng, tối giản)" style="flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:11px">';
|
| 695 |
h += '<button id="pid-ai-bg-go" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">🎨 Tạo</button></div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
h += '<div style="display:flex;gap:6px;margin-bottom:6px;align-items:center"><span style="color:#b88ae8;font-size:10px;padding:3px 8px;background:#1a1420;border:1px solid #8a5cb8;border-radius:6px">✨ SenseNova-U1-8B-MoT · ZeroGPU</span><a href="https://huggingface.co/sensenova/SenseNova-U1-8B-MoT" target="_blank" style="color:#5cb87a;font-size:9px">model</a></div>';
|
| 697 |
h += '<div id="pid-ai-bg-status" style="color:#888;font-size:10px;margin-bottom:6px"></div><div id="pid-ai-bg-preview" style="max-width:240px;margin:0 auto"></div>';
|
| 698 |
h += '</div>';
|
|
@@ -733,11 +760,36 @@ function _pidBuildDesigner(){
|
|
| 733 |
if(ot) ot.addEventListener('input', function(){ _pDebounce(_pidPreview, 200); });
|
| 734 |
var bgGo = document.getElementById('pid-ai-bg-go');
|
| 735 |
if(bgGo) bgGo.addEventListener('click', _pidAIGenBG);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
var txtGo = document.getElementById('pid-ai-text-go');
|
| 737 |
if(txtGo) txtGo.addEventListener('click', _pidAIGenText);
|
| 738 |
var mgBtn = document.getElementById('pid-merge-btn');
|
| 739 |
if(mgBtn) mgBtn.addEventListener('click', _pidMergeSlidesToBG);
|
| 740 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
['pid-texts','pid-ratio','pid-layout','pid-filter','pid-glow','pid-pos'].forEach(function(id){
|
| 742 |
['change','input'].forEach(function(evt){
|
| 743 |
var el = document.getElementById(id);
|
|
@@ -851,6 +903,24 @@ function _pidBgSamples(){
|
|
| 851 |
];
|
| 852 |
}
|
| 853 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
function _pidSetAll(on){
|
| 855 |
_pid.selected = [];
|
| 856 |
var list = document.getElementById('pid-slide-list');
|
|
|
|
| 289 |
}
|
| 290 |
}
|
| 291 |
}
|
| 292 |
+
/* draw text wrapped into <= maxLines, within a vertical band.
|
| 293 |
+
`pos` can be: 'top' | 'middle' | 'bottom' | a numeric fraction 0..1
|
| 294 |
+
(fraction = relative top of the text band, e.g. 0.16 = top 16% of the
|
| 295 |
+
canvas, 0.74 = lower 26% band). Numeric fractions are used by the modal
|
| 296 |
+
overlay so the "chữ trên ảnh" never overlaps the article text. */
|
| 297 |
function _pidDrawTextWrapped(ctx, W, H, text, pos, color, glow, baseScale, maxLines){
|
| 298 |
if(!text || !text.trim()) return 0;
|
| 299 |
var fs = Math.max(24, Math.round(W * baseScale));
|
|
|
|
| 312 |
}
|
| 313 |
var lh = Math.round(fs0 * 1.22);
|
| 314 |
var blockH = Math.min(lines.length, maxLines) * lh;
|
| 315 |
+
var bandTop;
|
| 316 |
+
if(typeof pos === 'number'){
|
| 317 |
+
bandTop = Math.round(H * Math.max(0, Math.min(0.9, pos)));
|
| 318 |
+
} else if(pos === 'top'){
|
| 319 |
+
bandTop = Math.round(H * 0.08);
|
| 320 |
+
} else if(pos === 'bottom'){
|
| 321 |
+
bandTop = Math.round(H * 0.76);
|
| 322 |
+
} else {
|
| 323 |
+
bandTop = Math.round(H / 2);
|
| 324 |
+
}
|
| 325 |
+
var isTopOrBottom = (pos === 'top' || pos === 'bottom' || (typeof pos === 'number' && (pos < 0.3 || pos > 0.6)));
|
| 326 |
+
var bandH = Math.round(H * (isTopOrBottom ? 0.24 : 0.4));
|
| 327 |
var y = bandTop + Math.round((bandH - blockH) / 2) + lh / 2;
|
| 328 |
+
/* scrim: always draw a semi-transparent dark band behind the text so it
|
| 329 |
+
stays readable on ANY background image (fix xem trc chữ pe ảnh) */
|
| 330 |
+
var scrimTop = y - lh / 2 - Math.round(fs0 * 0.2);
|
| 331 |
+
var scrimH = Math.min(lines.length, maxLines) * lh + Math.round(fs0 * 0.4);
|
| 332 |
+
if(scrimTop < 0) scrimTop = 0;
|
| 333 |
+
if(scrimTop + scrimH > H) scrimH = H - scrimTop;
|
| 334 |
+
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
| 335 |
+
ctx.fillRect(Math.round(W * 0.02), scrimTop, Math.round(W * 0.96), Math.round(scrimH));
|
| 336 |
if(glow === 'white'){ ctx.shadowColor = 'rgba(255,255,255,0.95)'; ctx.shadowBlur = Math.max(6, fs0 * 0.45); }
|
| 337 |
else if(glow === 'color'){ ctx.shadowColor = color; ctx.shadowBlur = Math.max(8, fs0 * 0.55); }
|
| 338 |
else if(glow === 'neon'){ ctx.shadowColor = '#00ffff'; ctx.shadowBlur = Math.max(12, fs0 * 0.75); }
|
|
|
|
| 341 |
var drawn = 0;
|
| 342 |
lines.forEach(function(l){
|
| 343 |
if(drawn >= maxLines) return;
|
| 344 |
+
if(isTopOrBottom){ ctx.strokeText(l, W / 2, y); }
|
| 345 |
ctx.fillText(l, W / 2, y); y += lh; drawn++;
|
| 346 |
});
|
| 347 |
ctx.shadowBlur = 0;
|
|
|
|
| 429 |
fetch('/api/pid/gen_text', {
|
| 430 |
method: 'POST',
|
| 431 |
headers: {'Content-Type': 'application/json'},
|
| 432 |
+
body: JSON.stringify({topic: topic, model: model, num_lines: 8})
|
| 433 |
}).then(function(r){
|
| 434 |
if(!r.ok) return r.json().then(function(j){ throw new Error(j.error || 'Lỗi tạo text'); });
|
| 435 |
return r.json();
|
|
|
|
| 715 |
h += '<div style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">✨ Tạo ảnh nền bằng AI</div>';
|
| 716 |
h += '<div style="display:flex;gap:6px;margin-bottom:6px"><input id="pid-ai-prompt" placeholder="Mô tả ảnh nền (VD: sân vận động đêm, cờ đỏ sao vàng, tối giản)" style="flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:11px">';
|
| 717 |
h += '<button id="pid-ai-bg-go" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">🎨 Tạo</button></div>';
|
| 718 |
+
h += '<div style="margin-bottom:6px"><div style="color:#888;font-size:10px;margin-bottom:4px">🎨 Phong cách phổ biến (bấm để thêm vào mô tả):</div><div id="pid-ai-style-chips" style="display:flex;gap:5px;flex-wrap:wrap">';
|
| 719 |
+
_pidBgStyles().forEach(function(s){
|
| 720 |
+
h += '<button class="pid-style-chip" data-style="' + _esc(s.suffix) + '" style="background:#1a2830;border:1px solid #2d8659;color:#7fe0a8;border-radius:14px;padding:4px 10px;font-size:10px;cursor:pointer">' + _esc(s.label) + '</button>';
|
| 721 |
+
});
|
| 722 |
+
h += '</div></div>';
|
| 723 |
h += '<div style="display:flex;gap:6px;margin-bottom:6px;align-items:center"><span style="color:#b88ae8;font-size:10px;padding:3px 8px;background:#1a1420;border:1px solid #8a5cb8;border-radius:6px">✨ SenseNova-U1-8B-MoT · ZeroGPU</span><a href="https://huggingface.co/sensenova/SenseNova-U1-8B-MoT" target="_blank" style="color:#5cb87a;font-size:9px">model</a></div>';
|
| 724 |
h += '<div id="pid-ai-bg-status" style="color:#888;font-size:10px;margin-bottom:6px"></div><div id="pid-ai-bg-preview" style="max-width:240px;margin:0 auto"></div>';
|
| 725 |
h += '</div>';
|
|
|
|
| 760 |
if(ot) ot.addEventListener('input', function(){ _pDebounce(_pidPreview, 200); });
|
| 761 |
var bgGo = document.getElementById('pid-ai-bg-go');
|
| 762 |
if(bgGo) bgGo.addEventListener('click', _pidAIGenBG);
|
| 763 |
+
var styleBox = document.getElementById('pid-ai-style-chips');
|
| 764 |
+
if(styleBox){
|
| 765 |
+
styleBox.addEventListener('click', function(e){
|
| 766 |
+
var b = e.target.closest ? e.target.closest('.pid-style-chip') : null;
|
| 767 |
+
if(!b) return;
|
| 768 |
+
var inp = document.getElementById('pid-ai-prompt');
|
| 769 |
+
if(!inp) return;
|
| 770 |
+
var suffix = b.getAttribute('data-style') || '';
|
| 771 |
+
if(!suffix) return;
|
| 772 |
+
var cur = (inp.value || '').trim();
|
| 773 |
+
if(cur.indexOf(suffix) >= 0){
|
| 774 |
+
inp.value = cur.replace(new RegExp('[,]?\\s*' + suffix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '').replace(/,\s*,/g, ',').replace(/^,\s*|\s*,$/g, '').trim();
|
| 775 |
+
b.style.borderColor = '#333'; b.style.color = '#7fe0a8';
|
| 776 |
+
} else {
|
| 777 |
+
inp.value = cur ? cur + ', ' + suffix : suffix;
|
| 778 |
+
b.style.borderColor = '#ffd700'; b.style.color = '#ffd700';
|
| 779 |
+
}
|
| 780 |
+
inp.focus();
|
| 781 |
+
});
|
| 782 |
+
}
|
| 783 |
var txtGo = document.getElementById('pid-ai-text-go');
|
| 784 |
if(txtGo) txtGo.addEventListener('click', _pidAIGenText);
|
| 785 |
var mgBtn = document.getElementById('pid-merge-btn');
|
| 786 |
if(mgBtn) mgBtn.addEventListener('click', _pidMergeSlidesToBG);
|
| 787 |
|
| 788 |
+
var prevBtn = document.getElementById('pid-preview-btn');
|
| 789 |
+
if(prevBtn) prevBtn.addEventListener('click', function(){ _pidPreview(); });
|
| 790 |
+
var saveBtn = document.getElementById('pid-save-btn');
|
| 791 |
+
if(saveBtn && typeof _pidSave === 'function') saveBtn.addEventListener('click', _pidSave);
|
| 792 |
+
|
| 793 |
['pid-texts','pid-ratio','pid-layout','pid-filter','pid-glow','pid-pos'].forEach(function(id){
|
| 794 |
['change','input'].forEach(function(evt){
|
| 795 |
var el = document.getElementById(id);
|
|
|
|
| 903 |
];
|
| 904 |
}
|
| 905 |
|
| 906 |
+
/* Popular background styles for the AI (SenseNova) background generator.
|
| 907 |
+
Clicking a style chip appends its English style suffix to the prompt so the
|
| 908 |
+
image model renders a well-known look (photo-real, 3D render, watercolor…). */
|
| 909 |
+
function _pidBgStyles(){
|
| 910 |
+
return [
|
| 911 |
+
{ label: '📷 Chân thực', suffix: 'photorealistic, high detail' },
|
| 912 |
+
{ label: '🎨 3D render', suffix: '3d render, octane render, cinematic' },
|
| 913 |
+
{ label: '🌆 Neon', suffix: 'neon lights, cyberpunk, glowing' },
|
| 914 |
+
{ label: '💧 Màu nước', suffix: 'watercolor painting, soft edges' },
|
| 915 |
+
{ label: '🖌️ Anime', suffix: 'anime style, vibrant colors' },
|
| 916 |
+
{ label: '🌫️ Tối giản', suffix: 'minimalist, flat design, clean' },
|
| 917 |
+
{ label: '🌌 Vũ trụ', suffix: 'space, galaxy, stars, deep blue' },
|
| 918 |
+
{ label: '🔥 Ấm áp', suffix: 'warm sunset light, golden hour' },
|
| 919 |
+
{ label: '❄️ Lạnh', suffix: 'cool blue tones, winter, frosty' },
|
| 920 |
+
{ label: '🏛️ Cổ điển', suffix: 'classic oil painting, vintage' },
|
| 921 |
+
];
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
function _pidSetAll(on){
|
| 925 |
_pid.selected = [];
|
| 926 |
var list = document.getElementById('pid-slide-list');
|