feat(overlay): 支援 data URI 圖片
Docs/ChangeHistory.mdDocs/MEDIA_CACHE.mdOtherTool/live_engine/README.mdOtherTool/live_engine/core/emoji_parser.pyOtherTool/live_engine/core/engine.pyOtherTool/live_engine/renderer/overlay.pyOtherTool/live_engine/renderer/texture_loader.pyOtherTool/live_engine/tests/test_ad_overlay.pyOtherTool/live_engine/tests/test_emoji_parser.pyOtherTool/live_engine/tests/test_texture_loader.pytodo.md
commit e01473e043675c4485c8fd4fedaf603fe50ce8a6Author: Nuclear0709 <agp05432@gmail.com>Date: Tue Nov 10 20:10:20 2026 +0800 feat(overlay): 支援 data URI 圖片 - 訊息/AdOverlay icon/頭像/禮物接受 png/jpeg/webp/gif data URI - texture worker 本機解碼,不發 HTTP、不寫 emoji 磁碟快取 - data URI 使用 content hash 作 texture key,日誌不輸出 base64 - 補 Python 解析器、AdOverlay 與 texture loader 測試 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,30 @@ # ChangeHistory +## 2026.11.10 20:20 Python overlay data URI 支援++**類型**: 功能 · **檔案**: `OtherTool/live_engine/core/emoji_parser.py`、`OtherTool/live_engine/renderer/texture_loader.py`、`OtherTool/live_engine/core/engine.py`、`OtherTool/live_engine/renderer/overlay.py`、`OtherTool/live_engine/tests/test_emoji_parser.py`、`OtherTool/live_engine/tests/test_ad_overlay.py`、`OtherTool/live_engine/tests/test_texture_loader.py`、`OtherTool/live_engine/README.md`++### 修改++- 訊息解析器支援 png/jpeg/webp/gif data URI;`strip_image_urls` 也會移除內嵌 base64。+- TextureLoader 在 worker thread 本機解碼 data URI,不走 HTTP,也不寫入 emoji 磁碟快取。+- data URI 使用 content hash 作為 texture key,避免把完整 base64 當成快取鍵。+- 頭像 `img`、禮物 `giftImg`、贊助 `AdOverlay.iconURL` 與訊息內嵌圖片都走同一條解碼路徑。+- 日誌只輸出 `data:image/...;base64,<N chars>`,不寫入 base64。+- 補 Python 解析器、AdOverlay 與 texture loader 測試。++### 限制++- data URI 單張 encoded 上限 256 KiB;SVG/PDF 拒絕。+- 4 MP/2048 px 仍由顯示端解碼限制;伺服器不切片。+- 通知(Bark/ntfy)仍不帶 inline icon。++### 驗證++- `python -m pytest OtherTool/live_engine/tests/test_emoji_parser.py OtherTool/live_engine/tests/test_ad_overlay.py OtherTool/live_engine/tests/test_texture_loader.py -q`++---+ ## 2026.11.10 20:00 Inline data URI 驗證與通知剝離 **類型**: 功能 · **檔案**: `ScriptLib/media/inline.cjs`、`ScriptLib/notify/format.mjs`、`Test/inline_image.test.cjs`、`Test/notify.test.mjs`、`Test/media_cache.test.cjs`、`Docs/CHAT_API.md`、`Docs/MEDIA_CACHE.md`、`Docs/TEST_FILES_REFERENCE.md`diff --git a/Docs/MEDIA_CACHE.md b/Docs/MEDIA_CACHE.md--- a/Docs/MEDIA_CACHE.md+++ b/Docs/MEDIA_CACHE.md@@ -43,6 +43,7 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: - `ScriptLib/media/inline.cjs` 驗證格式、encoded 大小、數量與總量;不切片、不 fallback HTTP。 - 通知會移除 body 中的 data URI,且外部 Bark/ntfy 不帶 data URI icon。 - Live 顯示保留 inline;歷史持久化不應直接寫 base64,必要時 materialize 成 `/media/<content-hash>`。+- Python overlay 已在 texture worker 本機解碼 png/jpeg/webp/gif data URI;日誌只記錄類型與長度。 ## 安全限制 diff --git a/OtherTool/live_engine/README.md b/OtherTool/live_engine/README.md--- a/OtherTool/live_engine/README.md+++ b/OtherTool/live_engine/README.md@@ -11,7 +11,7 @@ - **文字自動換行**,可選系統字型 - **訊息平滑動畫** — 進場、滾動、淡出 - **超出視窗自動滾動** — 最新訊息保持在可見區域-- **內嵌圖片 URL 解析** — 自動下載並渲染訊息中的圖片 URL(Discord CDN 等)+- **內嵌圖片 URL 解析** — 自動下載並渲染訊息中的 HTTP(S) 圖片;也接受本機解碼的 png/jpeg/webp/gif data URI(單張上限 256 KiB),不發出 HTTP - **非阻塞圖片載入** — 頭像/禮物/表情的網路下載與解碼都在背景下載執行緒(同時最多 4 個),繪製執行緒只負責上傳 GL 紋理(每幀最多 4 張),不會卡住畫面 - **紋理 LRU 快取** — 頭像/禮物/表情共用最多 300 張的 LRU,長時間直播不會無限成長;表情另有**本地磁碟快取**(`cache/emojis/`)跨啟動復用,同 URL 不下載第二次 - **表情跟隨字體** — 大小比例跟隨字體設定(font_size × 1.6),不再寫死@@ -26,9 +26,9 @@ - **佇列管理** — 佇列上限、滿載策略(跳過/停止舊/清空) - **可調參數** — 語速、音量、語音、最小/最大字數 - **疊加層設定 GUI** — 五頁分頁:基本(字型、大小、快捷鍵)、間距、位置(拖曳)、計時器、贊助橫幅(樣式自訂)-- **贊助橫幅 (AdOverlay)** — 接收 `AdOverlay` 訊息顯示個性化贊助橫幅:圓形頭像 + 用戶名 + 內嵌表情與自動換行文字,可自訂背景/邊框/文字顏色、頭像大小與垂直微調、顯示時長;顯示期間聊天訊息自動讓路並暫停清理,結束後滑回原位+- **贊助橫幅 (AdOverlay)** — 接收 `AdOverlay` 訊息顯示個性化贊助橫幅:圓形頭像 + 用戶名 + 內嵌表情與自動換行文字,可自訂背景/邊框/文字顏色、頭像大小與垂直微調、顯示時長;`iconURL` 與文字圖片也接受 data URI;顯示期間聊天訊息自動讓路並暫停清理,結束後滑回原位 - **字型選擇** — GDI EnumFontFamiliesW 列舉系統所有已安裝字型-- **pytest 測試** — 核心邏輯 57 項單元測試+- **pytest 測試** — 核心邏輯 62 項單元測試 --- @@ -72,6 +72,7 @@ live_engine/ │ ├── test_scene.py │ ├── test_overlay_config.py │ ├── test_speech_filter.py+│ ├── test_texture_loader.py # data URI 本機解碼與大小限制 │ └── test_ad_overlay.py # 贊助橫幅節點與引擎讓路/暫停清理測試 ├── config/ │ ├── tts_settings.json # TTS 設定(執行期自動產生)@@ -304,11 +305,12 @@ edge-tts 合成的音訊透過 `Communicate.stream()` 串流累積在記憶體 python -m pytest tests/ ``` -執行 57 項單元測試,涵蓋:+執行 62 項單元測試,涵蓋: | 模組 | 測試內容 | | ------ | ---------- |-| `test_emoji_parser` | 圖片 URL 解析、文字分段、`strip_image_urls` |+| `test_emoji_parser` | 圖片 URL/data URI 解析、文字分段、`strip_image_urls` |+| `test_texture_loader` | data URI 本機解碼、SVG 與過大資料拒絕 | | `test_speech_filter` | URL/Emoji 過濾、關鍵字封鎖與取代、數字過濾 | | `test_scene` | ChatNode 資料欄位、生命週期(alpha/dead) | | `test_overlay_config` | JSON 讀寫、預設值合併、遺漏鍵值處理 |diff --git a/OtherTool/live_engine/core/emoji_parser.py b/OtherTool/live_engine/core/emoji_parser.py--- a/OtherTool/live_engine/core/emoji_parser.py+++ b/OtherTool/live_engine/core/emoji_parser.py@@ -1,11 +1,25 @@ import re +INLINE_IMAGE_RE = re.compile(+ r'data:image/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}',+ re.IGNORECASE+)+ IMAGE_URL_RE = re.compile(- r'https?://[^\s]+?\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?',+ r'(?:https?://[^\s]+?\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?'+ r'|data:image/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2})', re.IGNORECASE ) +def display_image_url(url: str, tail: int = 40) -> str:+ """日誌用短字串;data URI 只印類型與長度,不寫入 base64。"""+ if isinstance(url, str) and url.lower().startswith('data:image/'):+ mime = url.split(';', 1)[0]+ return f"{mime};base64,<{len(url)} chars>"+ return (url or '')[-tail:]++ def parse_message(text): if not text: return [{"type": "text", "content": ""}]@@ -25,4 +39,6 @@ def parse_message(text): def strip_image_urls(text):+ if not text:+ return text return IMAGE_URL_RE.sub("", text).strip()diff --git a/OtherTool/live_engine/core/engine.py b/OtherTool/live_engine/core/engine.py--- a/OtherTool/live_engine/core/engine.py+++ b/OtherTool/live_engine/core/engine.py@@ -3,7 +3,7 @@ from network.socket_server import drain_messages from core.scene import ChatNode from core.ad_overlay import AdOverlayNode from core.tts import tts_service-from core.emoji_parser import strip_image_urls+from core.emoji_parser import strip_image_urls, display_image_url from core.debug_log import log_error from PyQt6.QtGui import QColor@@ -205,7 +205,7 @@ class Engine: tl = w.texture_loader for seg in new_node.segments: if seg["type"] == "image":- print(f"preloading emoji: {seg['url'][-40:]}")+ print(f"preloading emoji: {display_image_url(seg['url'])}") tl.preload_emoji(seg["url"]) if raw_text and data.get("type") == "StreamMessage":@@ -270,7 +270,7 @@ class Engine: emoji_size = int(self.ad_overlay_font_size * 1.6) for seg in node.segments: if seg["type"] == "image":- print(f"preloading ad emoji: {seg['url'][-40:]}")+ print(f"preloading ad emoji: {display_image_url(seg['url'])}") tl.preload_emoji(seg["url"], emoji_size) if node.use_tts:diff --git a/OtherTool/live_engine/renderer/overlay.py b/OtherTool/live_engine/renderer/overlay.py--- a/OtherTool/live_engine/renderer/overlay.py+++ b/OtherTool/live_engine/renderer/overlay.py@@ -45,6 +45,7 @@ from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import QPoint from core.debug_log import log, log_error, log_warn+from core.emoji_parser import display_image_url from core.hotkey import GlobalHotkey from gui.overlay_settings import load_overlay_config, save_overlay_config@@ -573,7 +574,7 @@ class Overlay(QOpenGLWidget): current_x += emoji_size + 1 else: if not hasattr(n, '_emoji_logged'):- log(f"emoji not cached: {seg['url'][-40:]}")+ log(f"emoji not cached: {display_image_url(seg['url'])}") n._emoji_logged = True gift_size = 28diff --git a/OtherTool/live_engine/renderer/texture_loader.py b/OtherTool/live_engine/renderer/texture_loader.py--- a/OtherTool/live_engine/renderer/texture_loader.py+++ b/OtherTool/live_engine/renderer/texture_loader.py@@ -1,5 +1,8 @@+import base64+import binascii import os import hashlib+import re import threading from collections import OrderedDict @@ -9,6 +12,7 @@ from PyQt6.QtCore import QRectF, Qt from OpenGL.GL import * from core.debug_log import log_warn+from core.emoji_parser import display_image_url EMOJI_DISK_CACHE = os.path.join(@@ -24,6 +28,35 @@ MAX_FETCH_WORKERS = 4 MAX_UPLOADS_PER_FRAME = 4 REQUEST_TIMEOUT = 5 USER_AGENT = "Mozilla/5.0"+MAX_INLINE_ENCODED = 256 * 1024+INLINE_DATA_RE = re.compile(+ r'^data:(image/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$',+ re.IGNORECASE+)+++def inline_data_bytes(url: str) -> bytes:+ """解碼受支援的 inline data URI;不發 HTTP、不寫入日誌內容。"""+ match = INLINE_DATA_RE.match(url or '')+ if not match:+ raise ValueError('不支援的 inline image')+ if len(url.encode('utf-8')) > MAX_INLINE_ENCODED:+ raise ValueError('inline image 過大')+ payload = match.group(2)+ payload += '=' * (-len(payload) % 4)+ try:+ data = base64.b64decode(payload, validate=True)+ except (binascii.Error, ValueError) as error:+ raise ValueError('inline base64 無效') from error+ if not data:+ raise ValueError('inline image 為空')+ return data+++def _url_key(url: str) -> str:+ if isinstance(url, str) and url.lower().startswith('data:image/'):+ return 'inline:' + hashlib.sha256(url.encode('utf-8')).hexdigest()+ return url def _emoji_disk_path(url):@@ -41,7 +74,9 @@ def qimage_to_bytes(img: QImage): def _fetch_bytes(url):- """Blocking network fetch; only ever called on a worker thread."""+ """Blocking fetch;data URI 只在本機解碼,不發出 HTTP。"""+ if isinstance(url, str) and url.lower().startswith('data:image/'):+ return inline_data_bytes(url) res = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=REQUEST_TIMEOUT) if res.status_code != 200: raise ValueError(f"HTTP {res.status_code}")@@ -152,7 +187,7 @@ class TextureLoader: if image is not None and kind == "circular": image = _circular(image, size) except Exception as error:- log_warn(f"texture fetch failed ({kind}): {url[-60:]} -> {error}")+ log_warn(f"texture fetch failed ({kind}): {display_image_url(url, 60)} -> {error}") image = None finally: with self._lock:@@ -167,6 +202,11 @@ class TextureLoader: self._fetch_slots.release() def _load_emoji_image(self, url, size):+ if isinstance(url, str) and url.lower().startswith('data:image/'):+ img = _decode(inline_data_bytes(url))+ if img is None:+ raise ValueError("QImage decode failed")+ return _scale(img, size) disk_path = _emoji_disk_path(url) if os.path.exists(disk_path): img = QImage(disk_path)@@ -195,22 +235,22 @@ class TextureLoader: def load_emoji(self, url, size=24): if not url: return None- return self._request(f"emoji:{url}:{size}", url, "emoji", size)+ return self._request(f"emoji:{_url_key(url)}:{size}", url, "emoji", size) def preload_emoji(self, url, size=24): if not url: return- self._request(f"emoji:{url}:{size}", url, "emoji", size)+ self._request(f"emoji:{_url_key(url)}:{size}", url, "emoji", size) def load_url(self, url): if not url: return None- return self._request(f"url:{url}", url, "plain")+ return self._request(f"url:{_url_key(url)}", url, "plain") def load_url_circular(self, url, size): if not url: return None- return self._request(f"circ:{url}:{size}", url, "circular", size)+ return self._request(f"circ:{_url_key(url)}:{size}", url, "circular", size) def process_pending(self): """Upload decoded images to GL textures. Must run on the GL thread."""diff --git a/OtherTool/live_engine/tests/test_ad_overlay.py b/OtherTool/live_engine/tests/test_ad_overlay.py--- a/OtherTool/live_engine/tests/test_ad_overlay.py+++ b/OtherTool/live_engine/tests/test_ad_overlay.py@@ -45,6 +45,13 @@ class TestAdOverlayNode: assert n.has_emoji assert any(s["type"] == "image" for s in n.segments) + def test_inline_data_uri_icon_and_message(self):+ url = "data:image/png;base64,iVBORw0KGgo="+ n = AdOverlayNode({"user": "u", "message": f"hello {url}", "iconURL": url})+ assert n.icon_url == url+ assert n.has_emoji+ assert n.segments[1]["url"] == url+ def test_default_duration(self): n = AdOverlayNode({"user": "u", "message": "hi"}) assert n.duration == 10.0diff --git a/OtherTool/live_engine/tests/test_emoji_parser.py b/OtherTool/live_engine/tests/test_emoji_parser.py--- a/OtherTool/live_engine/tests/test_emoji_parser.py+++ b/OtherTool/live_engine/tests/test_emoji_parser.py@@ -77,6 +77,12 @@ class TestParseMessage: assert [s["type"] for s in segs] == ["text", "image", "text", "image", "text"] assert [s.get("content", "") for s in segs if s["type"] == "text"] == ["a ", " b ", " c"] + def test_inline_data_uri_image(self):+ url = "data:image/png;base64,iVBORw0KGgo="+ segs = parse_message(f"look {url} end")+ assert [s["type"] for s in segs] == ["text", "image", "text"]+ assert segs[1]["url"] == url+ class TestStripImageUrls: def test_plain_text(self):@@ -90,3 +96,7 @@ class TestStripImageUrls: def test_no_url(self): assert strip_image_urls("just text") == "just text"++ def test_inline_data_uri_removed(self):+ url = "data:image/png;base64,iVBORw0KGgo="+ assert "data:image" not in strip_image_urls(f"look {url} end")diff --git a/OtherTool/live_engine/tests/test_texture_loader.py b/OtherTool/live_engine/tests/test_texture_loader.py--- /dev/null+++ b/OtherTool/live_engine/tests/test_texture_loader.py@@ -0,0 +1,24 @@+import base64+import os+import sys++sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")++import pytest++from renderer.texture_loader import inline_data_bytes+++def test_inline_data_uri_decodes_locally():+ raw = b"fake-png-bytes"+ url = "data:image/png;base64," + base64.b64encode(raw).decode()+ assert inline_data_bytes(url) == raw+++def test_inline_data_uri_rejects_svg_and_oversize():+ with pytest.raises(ValueError):+ inline_data_bytes("data:image/svg+xml;base64,AAAA")+ huge = "data:image/png;base64," + "A" * (260 * 1024)+ with pytest.raises(ValueError):+ inline_data_bytes(huge)diff --git a/todo.md b/todo.md--- a/todo.md+++ b/todo.md@@ -23,6 +23,7 @@ - [x] monitor 歷史 SQLite 持久化、最近場次 API 與即時/歷史切換。 - [x] Signer TikTok 導航節流與 WAF 403 冷卻,避免 fetch page 重建風暴。 - [x] 圖片媒體快取 Phase 1:本地 `/media/<hash>`、SocketHub 頭像/禮物圖與 AdOverlay 圖片改寫。+- [x] Python overlay 支援 img/giftImg/AdOverlay icon 與訊息內嵌 data URI。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。 ## 表情映射後續(2026-11-06)