app · db2d920

Dev index Recent changes / versions Changelog About TODO

修正贊助訊息無副檔名圖片 URL 的解析與分頁

db2d920 · 2026-11-12 07:43 · app
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.mdindex a446df5..762daf0 100644--- 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.mdindex 1d5d9f6..3f5d70f 100644--- 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.swiftindex eadc407..7c76db2 100644--- 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.swiftindex 84547ad..f74b362 100644--- 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.swiftindex a9aa98b..6702142 100644--- 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.swiftindex c63998e..3864908 100644--- 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")