bep40 commited on
Commit
2ae37a6
·
1 Parent(s): 9417af0

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 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
- return JSONResponse({'posts': base._load_ai_wall()[:80]})
 
 
 
 
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():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H],'persistent':HAS_PERSISTENT})
 
 
 
 
 
 
 
 
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. Posts created by the AI pipeline often
997
- store `created` as a millisecond epoch; some older posts (auto_rewrite)
998
- omit it and instead embed the timestamp in the `id` (Unix ms). Fall back
999
- to 0 so sorting never crashes."""
1000
  for field in ("created", "ts"):
1001
  v = p.get(field)
1002
  if v is not None:
1003
  try:
1004
- return int(v)
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
- return int(pid)
 
 
 
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
- 1) Hide auto-generated posts that have a wrong/placeholder image (no
 
 
 
1019
  valid image at all — pollinations.ai, vnexpress logo, empty) or have
1020
- no slide-AI content (no slides / no short-video / no embed). These
1021
- are the 'bài đăng tự động bị sai ảnh / ko có thông tin slide AI' that
1022
- don't match the original article. This filter is VIEW-ONLY — it does
1023
- NOT delete from the persistent store.
1024
- 2) Sort newest-first by creation timestamp, mixing all sources (FPT,
1025
- slide rewrite AI, etc.) without source separation."""
 
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 _is_auto_post(p) and not (_post_has_valid_image(p) and _post_has_slide_info(p)):
1034
- # auto post with a wrong/placeholder/missing image or no slide AI
 
 
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 (8-9 sentences)."""
206
- news = _fetch_news(topic, limit=8)
 
 
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[:100]
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
- else:
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"Gần đây, dư luận đã theo dõi sát sao các phát triến liên quan đến {t2}, thu hút sự quan tâm của báo chí và cộng đồng trong cả nước.")
226
  else:
227
- parts.append(f"Tuy nhiên, chưa có nhiều thông tin mới trên kênh tin, nhưng {t2} vẫn giữ tính thời sự.")
228
- for f in frags[:4]:
229
- if not f: continue
230
- fc = f[0].lower() + f[1:]
231
- parts.append(f"{fc} — một diễn biến mới được báo cáo gần đây.")
232
- parts.append(f"Tất cả diễn biến trên cho thấy {t2} đang là xu hướng quan tâm phổ biến, thu hút chuyên gia và độc giả.")
233
- parts.append(f"Điều này góp phần đưa chủ đề {t2} ngày càng nổi tiếng trên nền tảng truyền thông lớn.")
 
 
 
 
 
 
 
 
 
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(5, min(int(req.num_lines or 5), 12))
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=20260904pid11"></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>
 
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 (top|middle|bottom fraction) */
 
 
 
 
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 = (pos === 'top') ? Math.round(H * 0.08) : (pos === 'bottom') ? Math.round(H * 0.76) : Math.round(H / 2);
312
- var bandH = Math.round(H * (pos === 'top' ? 0.22 : pos === 'bottom' ? 0.22 : 0.4));
 
 
 
 
 
 
 
 
 
 
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(pos === 'top' || pos === 'bottom'){ ctx.strokeText(l, W / 2, y); }
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: 5})
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');