修正贊助訊息無副檔名圖片 URL 的解析與分頁
Docs/ChangeHistory.mdDocs/socket-wire-protocol.mdliveAPP/PIPContent.swiftliveAPP/PIPService.swiftliveAPP/Socket.swiftliveAPPTests/ImageDataURITests.swift
commit db2d9204008ca38c0549b27cf88480e7b503a0fdAuthor: Nuclear0709 <agp05432@gmail.com>Date: Thu Nov 12 07:43:15 2026 +0800 修正贊助訊息無副檔名圖片 URL 的解析與分頁 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -2,6 +2,7 @@ ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 +- 贊助訊息 `text` 內的圖片 URL 不再只依副檔名判斷:`extractAllImageURLs` 可接受呼叫端提供的 `knownImageURLs`(例如 `iconURL`),因此 `/media/<hash>` 這類沒有 `.png` 的網址也能被解析成行內 emoji,不再把原始 URL 留在文案中被切成多頁。插圖位置改以 UTF-16 index 計算,修正 emoji 在前時與 CoreText/NSString 位置不一致;一般非圖片連結仍保留原文。 - 音訊 PTS 缺口改為大小分流:小於 250 ms 才補零;超過時重建來源時間軸(`rebase`),不再用數秒靜音塞滿環形緩衝並擠掉歷史音訊。`AudioRingBuffer` 產生最多 8 筆 `GapEvent`,含前後 PTS、gap samples、mode、緩衝前後量與 dropped samples;SCK/ReplayKit 會寫出 `[CaptureAudioGap]` event-level log,週期摘要也新增 `zeroFill`/`rebase` 計數。 - `CaptureMediaPipeline.audioSettings` 在麥克風啟用時改用 mic(track 1)作為 Mixer 主時鐘,輸出格式仍取 App 軌;避免 App 軌靜默但持續 callback 時,mic 被當成非主軌反覆 align/丟樣本而斷續。mic 停止或關閉時,底層 `activeClockTrack` 會 fallback 到 App 軌。 - 2ch mixer 測試現在會斷言輸出內容不是靜音(`channelRMS`),補上先前只檢查格式、未檢查實際混音的盲點。diff --git a/Docs/socket-wire-protocol.md b/Docs/socket-wire-protocol.md--- a/Docs/socket-wire-protocol.md+++ b/Docs/socket-wire-protocol.md@@ -173,6 +173,7 @@ App 會在本機解碼,不發出 HTTP 請求;只接受 `;base64`,拒絕 SV - 視覺:金底 `rgba(0.9, 0.55, 0.05, 0.88)`、圓角 `10`、白色粗體名稱 + 灰色內文 - 持續時間:5 秒後自動清除,最後 0.5 秒 alpha 淡出 - 頭像:非同步透過 `PiPImageCache` 取得(HTTP 或 `data:image/...;base64,...` 本機解碼),圓形裁切;無資料時顯示 `star.fill` 系統圖示。`text` 內嵌 emoji 也套用相同 data URI 限制。+- 圖片 URL 判定:一般情況需要圖片副檔名(`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`)或合法 `data:image`。若媒體伺服器使用沒有副檔名的 `/media/<hash>`,呼叫端可把已知圖片 URL(例如 `iconURL`)放進 `knownImageURLs`;一般未知 URL 不會被誤判,會保留為文字。`text` 內的圖片位置使用 UTF-16 index,與 CoreText/NSString 對齊。 - PiP 關閉或收到記憶體警告時立即清除 ---diff --git a/liveAPP/PIPContent.swift b/liveAPP/PIPContent.swift--- a/liveAPP/PIPContent.swift+++ b/liveAPP/PIPContent.swift@@ -913,7 +913,9 @@ final class PIPServiceMessages { var lineStartIndex = 0 for (index, line) in messageLines.enumerated() {- let lineLen = line.count+ // emojiPositions 由 extractAllImageURLs 以 UTF-16 index 回報,+ // 這裡必須用 NSString 長度累加,不能用 Swift Character 數。+ let lineLen = (line as NSString).length var lineEmojiURLs: [String] = [] var lineEmojiIndices: [Int] = [] for (ei, pos) in emojiPositions.enumerated() {@@ -1179,14 +1181,30 @@ final class PIPServiceMessages { - static func extractAllImageURLs(from message: String, maxURLs: Int = 20, placeholder: String = "\u{2003}") -> (cleaned: String, imageURLs: [String], imagePositions: [Int]) {+ /// 抽出訊息中的圖片 URL,並回傳清洗後文字與 UTF-16 位置。+ ///+ /// - Parameter knownImageURLs: 呼叫端已知的圖片 URL,例如贊助訊息的+ /// `iconURL`。媒體伺服器可能使用沒有副檔名的 `/media/<hash>`,單靠+ /// 副檔名無法辨識;只接受呼叫端明確提供的 URL,不把一般連結誤判成圖片。+ static func extractAllImageURLs(+ from message: String,+ maxURLs: Int = 20,+ placeholder: String = "\u{2003}",+ knownImageURLs: [String] = []+ ) -> (cleaned: String, imageURLs: [String], imagePositions: [Int]) { let imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"]- let httpPattern = "https?://[^\\s]+\\.(?:" + imageExtensions.joined(separator: "|") + ")(?:\\?[^\\s]*)?"+ // 先抓所有 HTTP(S) URL,再依副檔名或呼叫端提供的清單決定是否視為圖片。+ // 不能只用「含圖片副檔名」的 pattern,否則 /media/<hash> 不會進入判斷。+ let httpPattern = "https?://[^\\s]+" let dataPattern = "data:image/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+" let pattern = "(?:" + httpPattern + "|" + dataPattern + ")" guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return (message, [], []) }+ let known = Set(knownImageURLs)+ let extensionPattern = try? NSRegularExpression(+ pattern: "\\.(?:" + imageExtensions.joined(separator: "|") + ")(?:[?#]|$)",+ options: [.caseInsensitive]) let nsRange = NSRange(message.startIndex..., in: message) let matches = regex.matches(in: message, range: nsRange)@@ -1202,21 +1220,38 @@ final class PIPServiceMessages { parts.append(String(message[lastEnd..<range.lowerBound])) } let token = String(message[range])- var accepted = urls.count < maxURLs- if token.lowercased().hasPrefix("data:image/") {+ let lowercased = token.lowercased()+ let isDataURI = lowercased.hasPrefix("data:image/")+ let isKnown = known.contains(token)+ let hasImageExtension = extensionPattern?.firstMatch(+ in: token, range: NSRange(token.startIndex..., in: token)) != nil+ var accepted = urls.count < maxURLs && (isKnown || hasImageExtension)++ if isDataURI { if let reference = ImageDataURI.reference(from: token),- embeddedBytes + reference.estimatedBytes <= ImageDataURI.maxTotalBytes {+ embeddedBytes + reference.estimatedBytes <= ImageDataURI.maxTotalBytes,+ urls.count < maxURLs { embeddedBytes += reference.estimatedBytes+ accepted = true } else { accepted = false } }+ if accepted {- let currentLen = parts.reduce(0) { $0 + $1.count }+ // CoreText 與 NSString 使用 UTF-16 index;emoji 是 surrogate pair,+ // 不能用 String.count,否則有 emoji 時圖片位置會錯開。+ let currentLen = parts.reduce(0) { $0 + $1.utf16.count } positions.append(currentLen) urls.append(token)+ parts.append(placeholder)+ } else if isDataURI {+ // 不把被拒絕的 base64 內容留在訊息或日誌中。+ parts.append(placeholder)+ } else {+ // 一般連結保留原文,不因無法判定為圖片而消失。+ parts.append(token) }- parts.append(placeholder) lastEnd = range.upperBound } diff --git a/liveAPP/PIPService.swift b/liveAPP/PIPService.swift--- a/liveAPP/PIPService.swift+++ b/liveAPP/PIPService.swift@@ -409,7 +409,9 @@ final class PIPService: NSObject, ObservableObject, @unchecked Sendable { messagesLayer?.setAdOverlayOffset(145) // 抽離文字中的圖片網址,用 \u{2003} 保留行內位置- let (cleanText, emojiURLs, emojiPositions) = PIPServiceMessages.extractAllImageURLs(from: text, placeholder: "\u{2003}")+ let knownImageURLs = iconURL.map { [$0] } ?? []+ let (cleanText, emojiURLs, emojiPositions) = PIPServiceMessages.extractAllImageURLs(+ from: text, placeholder: "\u{2003}", knownImageURLs: knownImageURLs) adOverlayCleanText = cleanText adOverlayEmojiURLs = emojiURLs adOverlayEmojiPositions = emojiPositionsdiff --git a/liveAPP/Socket.swift b/liveAPP/Socket.swift--- a/liveAPP/Socket.swift+++ b/liveAPP/Socket.swift@@ -1233,14 +1233,16 @@ class SocketServer:ObservableObject, @unchecked Sendable { logTo("收到廣告訊息:\(user) - \(text) Icon:\(String(describing: iconURL)) TTS:\(useTTS)") + let knownAdImageURLs = iconURL.map { [$0] } ?? [] if isNotifyApp {- let (cleanBody, inlineImages, _) = PIPServiceMessages.extractAllImageURLs(from: text, placeholder: "")+ let (cleanBody, inlineImages, _) = PIPServiceMessages.extractAllImageURLs(+ from: text, placeholder: "", knownImageURLs: knownAdImageURLs) postSystemNotification(title: user, body: cleanBody, imageURL: iconURL ?? "", inlineImages: inlineImages) } let (speechText, _, _) = PIPServiceMessages.extractAllImageURLs(- from: text, placeholder: "")+ from: text, placeholder: "", knownImageURLs: knownAdImageURLs) Task { @MainActor in if useTTS { TTSService.shared.speakStreamMessage(user: user, message: speechText, isMain: true, force: true)diff --git a/liveAPPTests/ImageDataURITests.swift b/liveAPPTests/ImageDataURITests.swift--- a/liveAPPTests/ImageDataURITests.swift+++ b/liveAPPTests/ImageDataURITests.swift@@ -38,6 +38,29 @@ struct ImageDataURITests { #expect(result.imagePositions.count == 1) } + @Test func extractsKnownImageURLWithoutFileExtension() {+ let url = "http://192.168.0.102:3332/media/5936554d"+ let result = PIPServiceMessages.extractAllImageURLs(+ from: "贊助訊息 \(url)", knownImageURLs: [url])+ #expect(result.imageURLs == [url])+ #expect(result.cleaned == "贊助訊息 \u{2003}")+ #expect(result.imagePositions == [5])+ }++ @Test func leavesUnknownURLWithoutFileExtensionAsText() {+ let url = "http://192.168.0.102:3332/media/5936554d"+ let result = PIPServiceMessages.extractAllImageURLs(from: "請看 \(url)")+ #expect(result.imageURLs.isEmpty)+ #expect(result.cleaned == "請看 \(url)")+ }++ @Test func imagePositionUsesUTF16WhenEmojiPrecedesURL() {+ let result = PIPServiceMessages.extractAllImageURLs(from: "🎉 http://example.com/a.png")+ #expect(result.imageURLs.count == 1)+ // 🎉 是 surrogate pair,佔 2 個 UTF-16 code units;空格再加 1。+ #expect(result.imagePositions == [3])+ }+ @Test func redactsDataURIInLogs() throws { let uri = try pngDataURI() let redacted = ImageDataURI.redacted("send \(uri) end")