接出編碼輸入緩衝診斷並分開 SCK/ReplayKit 上限
Docs/ChangeHistory.mdDocs/av-pipeline.mdPackage.resolvedPackages/HaishinKit/Sources/RTMPHaishinKit/RTMPHaishinKitStubs.swiftReplyKIT/SampleHandler.swiftSharedCapture/StreamDiagnostics.swiftSharedCapture/VideoInputBufferSettings.swiftTODO.mdliveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedliveAPP/Capture/ScreenCaptureSource.swiftliveAPP/Home/EncodingSettingsView.swiftliveAPP/Settings/Backup/SettingsBackupFormat.swiftliveAPP/StreamDiagnosticsView.swiftliveAPPTests/VideoInputBufferSettingsTests.swift
commit ecc7ceda2b44b33f505bc7480cfb4b5291598d3dAuthor: Nuclear0709 <agp05432@gmail.com>Date: Wed Nov 11 22:10:35 2026 +0800 接出編碼輸入緩衝診斷並分開 SCK/ReplayKit 上限 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -14,6 +14,7 @@ - Archive/Release workflow 以 release tag 覆寫 `MARKETING_VERSION`,並以 UTC 時間自動產生 `CURRENT_PROJECT_VERSION`:不修改 repo 的 `project.pbxproj`,但主 App 與 extension 的 `CFBundleShortVersionString`/`CFBundleVersion` 會是本次 release 值;CI 摘要記錄舊值與 effective 值,archive 後再驗證實際 Info.plist 相符。AltStore 來源因此直接取得正確 version/buildVersion,測試 target 的 `1.0` 仍不列入比較。 - DocC Pages 部署避免殘留卡住:workflow 層不再取消整個 run,只讓 DocC 建置 job 可被新 ref 取代;deploy job 保持 `cancel-in-progress: false`,新增 15 分鐘 job timeout 與 `deploy-pages` 10 分鐘輪詢 timeout,卡住的部署會讓 job 失敗並釋放 concurrency,不再無限期等到六小時。 - TTS 過濾器新增三段式「重複無意義字串」設定:關閉/整句都是 ASCII 同字元重複 3 次以上時跳過/移除重複 token 後朗讀其餘內容。`wwww 你好` 在第三段只念「你好」;`hahaha`、`abc`、`asweffeteteh` 與混合正常詞句不會被自動誤刪。設定寫入 `SpeechFilterSettings`、支援 JSON 匯入/匯出與既有備份;預設關閉。+- 編碼輸入緩衝接出 `VideoPipelineSnapshot.encoderInput`:顯示 byteLimit、已用 bytes、queued/peak、最舊影格、capacity/expired/oversized drop 與 input/output idle,並與 VT pending、RTMP queuedBytes 分開。SCK 預設 32 MB/150 ms、ReplayKit 預設 15 MB/100 ms,兩者分開設定;`BufferCount` 仍只是手動 frame cap。設定納入備份。 - `add_legacy_alternate_icons.sh` 不再把未被 `CFBundleIconFiles` 引用的 1024 `PersonalIcon.png` 複製到 App 根目錄;Assets.car 仍保留 marketing rendition,約減少 1.79 MB 的重複圖示資料。 - `AppIcon.appiconset` 原本的 `AppIcon 1.png`/`AppIcon 2.png` 不是不同尺寸,而是與 default 相同 SHA256 的 dark/tinted appearance 變體。已移除兩張重複檔,只保留單張 1024 default;iOS 其餘尺寸由 actool 產生,不再以 legacy 多尺寸重複塞入 catalog。 - GPU 旋轉處理設定頁改為 `AdaptiveSettingsGrid` 卡片:配置、畫布尺寸、方向與縮放三個區塊;寬螢幕並排、窄螢幕單欄,OD/AD 寬高欄位以同一列呈現,減少 iPad 全域捲動。diff --git a/Docs/av-pipeline.md b/Docs/av-pipeline.md--- a/Docs/av-pipeline.md+++ b/Docs/av-pipeline.md@@ -723,6 +723,19 @@ SampleHandler.logAudioHealthIfNeeded() (每幀,ReplayKit queue) `vtDrop` 只代表 VT 回報丟幀;App 的主動降幀是 `throughputThrottle`,下游拒收是 `outputRejected`,不能混為一談。 +### 編碼輸入緩衝(2026-11-11)++`VideoPipelineSnapshot.encoderInput` 是進入 VT 前的原始影格 queue,和 VT `pendingFrames`、RTMP `queuedBytes` 是三個不同階段。HaishinKit 佇列預設 `maxVideoBufferBytes=15 MiB`、`maxVideoBufferDuration=0.1s`;`BufferCount` 只是手動 frame cap,不是 MB 預算,且仍受 bytes/age 限制。++App 目前分開設定:++| 來源 | 預設上限 | 預設最長停留 |+| ------ | ------: | ------: |+| ScreenCaptureKit(主 App) | 32 MB | 150 ms |+| ReplayKit(擴展) | 15 MB | 100 ms |++設定在 `screenCaptureKitVideoBufferMB`/`screenCaptureKitVideoBufferDurationMS`、`replayKitVideoBufferMB`/`replayKitVideoBufferDurationMS`。診斷會顯示 `byteLimit`、`bytes`、`queued`、`peakBytes`、`oldestAge`、capacity/expired/oversized drops、input/output idle;若 `capacityDrops` 與 `queued ≈ limit` 增加才考慮放寬,若 `expiredDrops` 增加則應先查 encoder 或 worker 效率。+ ### 診斷邏輯 - `align-drop`:`AudioRingBuffer.align()` 丟棄非 main track 樣本。這是目前最可疑的 content-level 斷音來源(雙軌 PTS 基座差被當成 desync)。diff --git a/Package.resolved b/Package.resolved--- a/Package.resolved+++ b/Package.resolved@@ -6,9 +6,11 @@ "location" : "https://github.com/TwhomeGH/HaishinKitFixSwfit.git", "state" : { "branch" : "main",- "revision" : "9beb2a9314c2616e850790236185a31b2129d6ed"+ "revision" : "46bec3ac2ef987cc45825a7382e4cb07f21077b9" } } ], "version" : 2-}+}++diff --git a/Packages/HaishinKit/Sources/RTMPHaishinKit/RTMPHaishinKitStubs.swift b/Packages/HaishinKit/Sources/RTMPHaishinKit/RTMPHaishinKitStubs.swift--- a/Packages/HaishinKit/Sources/RTMPHaishinKit/RTMPHaishinKitStubs.swift+++ b/Packages/HaishinKit/Sources/RTMPHaishinKit/RTMPHaishinKitStubs.swift@@ -25,6 +25,7 @@ open class RTMPStream: StreamConvertible { open func publish(_: String) async throws {} open func close() async throws {} open func setVideoInputBufferCounts(_: Int) async {}+ open func setVideoInputBufferLimits(bytes _: Int, duration _: TimeInterval) {} public var videoSettings: VideoCodecSettings { get async { VideoCodecSettings() }diff --git a/ReplyKIT/SampleHandler.swift b/ReplyKIT/SampleHandler.swift--- a/ReplyKIT/SampleHandler.swift+++ b/ReplyKIT/SampleHandler.swift@@ -1022,6 +1022,9 @@ class SampleHandler: RPBroadcastSampleHandler , @unchecked Sendable{ let bufferPolicy = VideoBufferPolicy.resolve( rawValue: RPConfig.shared.state.BufferCount) await rtmpStream.setVideoInputBufferCounts(bufferPolicy.rawValue)+ let inputBuffer = VideoInputBufferSettings.load(from: SharedDefaults.group, source: .replayKit)+ await rtmpStream.setVideoInputBufferLimits(bytes: inputBuffer.budgetBytes,+ duration: inputBuffer.duration) // 在 frame 到來前先用 socket 配置設定 encoder video size。 // OD/odst 是最終畫布輸出;AD/dst 只作為 GPU 中間處理尺寸。diff --git a/SharedCapture/StreamDiagnostics.swift b/SharedCapture/StreamDiagnostics.swift--- a/SharedCapture/StreamDiagnostics.swift+++ b/SharedCapture/StreamDiagnostics.swift@@ -32,6 +32,46 @@ struct StreamDiagnosticsSnapshot: Codable, Sendable { var encoderHealth: VideoEncoderHealth? /// Mixer 各軌的 AVAudioConverter/ring buffer/align 摘要;舊來源為 nil。 var audioTracks: [AudioTrackDiagnostics]?+ /// RTMPStream 的編碼前原始影格佇列;與 VT pending、RTMP 網路佇列不同。+ var encoderInput: EncoderInputBufferDiagnostics?++ /// 編碼輸入佇列的唯讀摘要;nil 欄位代表該版本或階段沒有提供。+ struct EncoderInputBufferDiagnostics: Codable, Sendable {+ let availability: String+ let generation: UInt64?+ let queued: Int?+ let bytes: Int?+ let byteLimit: Int?+ let peakBytes: Int?+ let maxAge: Double?+ let oldestAge: Double?+ let manualFrameLimit: Int?+ let capacityDrops: Int?+ let expiredDrops: Int?+ let oversizedDrops: Int?+ let inputIdle: Double?+ let outputIdle: Double?++ /// 保留 queue 的可用狀態;不要把 ownerLockBusy 當成空佇列。+ static func from(_ stage: VideoQueueStageSnapshot) -> Self {+ let queue = stage.queue+ return Self(+ availability: stage.availability.rawValue,+ generation: stage.generation,+ queued: queue?.queued,+ bytes: queue?.bytes,+ byteLimit: queue?.byteLimit,+ peakBytes: queue?.peakBytes,+ maxAge: queue?.maxAge,+ oldestAge: queue?.oldestAge,+ manualFrameLimit: queue?.manualFrameLimit,+ capacityDrops: queue?.capacityDrops,+ expiredDrops: queue?.expiredDrops,+ oversizedDrops: queue?.oversizedDrops,+ inputIdle: queue?.inputIdle,+ outputIdle: queue?.outputIdle)+ }+ } /// 單一音軌的轉換器與緩衝診斷;只放純量,不攜帶 PCM buffer。 struct AudioTrackDiagnostics: Codable, Sendable, Identifiable {@@ -200,6 +240,7 @@ final class StreamDiagnosticsProbe: MediaMixerOutput, @unchecked Sendable { result.firstEncoderTiming = pipeline.encoder?.firstEncoderTiming result.lastEncoderTiming = pipeline.encoder?.lastEncoderTiming result.encoderHealth = pipeline.encoderHealth+ result.encoderInput = EncoderInputBufferDiagnostics.from(pipeline.encoderInput) result.videoSettings = "\(video.profileLevel) · \(Int(video.videoSize.width))×\(Int(video.videoSize.height)) · \(video.bitRate / 1000) kbps" result.audioSettings = "\(audio.format.rawValue) · \(audio.bitRate / 1000) kbps" result.encodedVideoFrames = pipeline.encoder?.events["encoderDelivered"]?.countdiff --git a/SharedCapture/VideoInputBufferSettings.swift b/SharedCapture/VideoInputBufferSettings.swift--- /dev/null+++ b/SharedCapture/VideoInputBufferSettings.swift@@ -0,0 +1,47 @@+import Foundation++/// 編碼前原始影格佇列的可調上限;與 RTMP 網路待送 bytes、VT pending 不同。+///+/// ScreenCaptureKit 在主 App 行程內,可承受較大的預算;ReplayKit 擴展只有+/// 約 50 MB 空間,因此兩者使用不同預設與上限。+struct VideoInputBufferSettings: Equatable, Sendable {+ enum Source: Equatable {+ case screenCaptureKit+ case replayKit+ }++ static let screenCaptureKitBudgetKey = "screenCaptureKitVideoBufferMB"+ static let screenCaptureKitDurationKey = "screenCaptureKitVideoBufferDurationMS"+ static let replayKitBudgetKey = "replayKitVideoBufferMB"+ static let replayKitDurationKey = "replayKitVideoBufferDurationMS"++ static let screenCaptureKitDefaultMB = 32+ static let screenCaptureKitDefaultDurationMS = 150+ static let replayKitDefaultMB = 15+ static let replayKitDefaultDurationMS = 100++ let budgetMB: Int+ let durationMS: Int++ var budgetBytes: Int { budgetMB * 1024 * 1024 }+ var duration: TimeInterval { Double(durationMS) / 1000 }++ /// 讀取已保存值並限制在安全範圍;缺少設定時使用來源專屬預設。+ static func load(from defaults: UserDefaults?, source: Source) -> Self {+ let keys: (budget: String, duration: String, defaultMB: Int, defaultMS: Int)+ switch source {+ case .screenCaptureKit:+ keys = (screenCaptureKitBudgetKey, screenCaptureKitDurationKey,+ screenCaptureKitDefaultMB, screenCaptureKitDefaultDurationMS)+ case .replayKit:+ keys = (replayKitBudgetKey, replayKitDurationKey,+ replayKitDefaultMB, replayKitDefaultDurationMS)+ }+ let storedMB = defaults?.object(forKey: keys.budget) as? Int ?? keys.defaultMB+ let storedMS = defaults?.object(forKey: keys.duration) as? Int ?? keys.defaultMS+ // ReplayKit 擴展保留較低上限;不要把主 App 的 64 MB 設定帶進擴展。+ let maximumMB = source == .screenCaptureKit ? 64 : 24+ return Self(budgetMB: min(max(storedMB, 8), maximumMB),+ durationMS: min(max(storedMS, 50), 500))+ }+}diff --git a/TODO.md b/TODO.md--- a/TODO.md+++ b/TODO.md@@ -398,6 +398,7 @@ API 遷移屬後續技術評估,不作為以上功能完成的條件;需要 - [x] **VT 編碼器健康摘要**:`VideoEncoderHealth` 已接 `pendingFrames`、VT drop/failure/yield/PTS reset;串流診斷頁先顯示摘要。待 fork CI、ReplyKit CI 與實機驗證。 - [x] **AVAudioConverter 診斷**:Mixer track 已有 convert status/input request/output frame 計數;AAC encoder converter 尚未接入。待 fork CI 與 AHealth UI 驗證。 - [x] **Keyframe/BufferCount 明確策略**:0 統一 automatic,BufferCount 0 映射 VT -1/nil,ReplayKit 與 SCK 共用 clamp;待 fork 測試與實機驗證。+- [x] **編碼輸入緩衝診斷與來源分流**:接出 `VideoPipelineSnapshot.encoderInput`(byteLimit/已用 bytes/queued/peak/oldest/capacity/expired/oversized drop/idle);SCK 預設 32 MB/150 ms,ReplayKit 預設 15 MB/100 ms,兩者分開設定並納入備份。待實機 A/B 驗證。 - [x] **Release 名稱自動分類**:`Scripts/classify_release.py` 依路徑與 trailer 產生 Core/UI、子系統與 Exp;Release 名稱與 body 由 `main.yml` 更新,tag 維持 `x.y.z`。待 push 後驗證下一個 Release。 - [x] **音訊環形緩衝格式/PTS 缺陷**:修 Float32/Int32 的 pointer offset、PTS 大幅倒退重建與 AudioTime 不連續重建;fork 本地 `56d1e93c`,尚未 push。待 macOS/iOS 測試與實機驗證。 - [x] **混音 main track fallback**:HaishinKit fork `14c41945` 已加入 active-clock 切換與 PTS 跳躍重設;尚未 push。待 push、更新 `Package.resolved` 與實機驗證 mic 啟停/雙軌混音。diff --git a/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved--- a/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved+++ b/liveAPP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved@@ -6,7 +6,7 @@ "location" : "https://github.com/TwhomeGH/HaishinKitFixSwfit.git", "state" : { "branch" : "main",- "revision" : "9beb2a9314c2616e850790236185a31b2129d6ed"+ "revision" : "46bec3ac2ef987cc45825a7382e4cb07f21077b9" } } ],@@ -17,3 +17,5 @@ ++diff --git a/liveAPP/Capture/ScreenCaptureSource.swift b/liveAPP/Capture/ScreenCaptureSource.swift--- a/liveAPP/Capture/ScreenCaptureSource.swift+++ b/liveAPP/Capture/ScreenCaptureSource.swift@@ -601,6 +601,9 @@ private final class ScreenSamplePump: NSObject, SCStreamOutput, @unchecked Senda startupStage = "pipeline.videoSettings" try await stream.setVideoSettings(video) await stream.setVideoInputBufferCounts(bufferPolicy.rawValue)+ let inputBuffer = VideoInputBufferSettings.load(from: defaults, source: .screenCaptureKit)+ await stream.setVideoInputBufferLimits(bytes: inputBuffer.budgetBytes,+ duration: inputBuffer.duration) await stream.setBitRateStrategy(StreamVideoAdaptiveBitRateStrategy(mamimumVideoBitrate: video.bitRate)) await conn.setReconnectEnabled(true) let reconnectSession = state.iddiff --git a/liveAPP/Home/EncodingSettingsView.swift b/liveAPP/Home/EncodingSettingsView.swift--- a/liveAPP/Home/EncodingSettingsView.swift+++ b/liveAPP/Home/EncodingSettingsView.swift@@ -10,6 +10,14 @@ struct EncodingSettingsView: View { @AppStorage("odstH", store: userDefaults) private var height = 0 @AppStorage("isLowLatencyRateControlEnabled", store: userDefaults) private var lowLatency = false @AppStorage("BufferCount", store: userDefaults) private var bufferCount = -1+ @AppStorage("screenCaptureKitVideoBufferMB", store: userDefaults)+ private var sckBufferMB = 32+ @AppStorage("screenCaptureKitVideoBufferDurationMS", store: userDefaults)+ private var sckBufferDurationMS = 150+ @AppStorage("replayKitVideoBufferMB", store: userDefaults)+ private var replayKitBufferMB = 15+ @AppStorage("replayKitVideoBufferDurationMS", store: userDefaults)+ private var replayKitBufferDurationMS = 100 @State private var result: H264CapabilityResult? @State private var loading = false @State private var refreshID = 0@@ -141,6 +149,41 @@ struct EncodingSettingsView: View { Text(AppLanguage.localized("gpu.buffer.recommendation")) .font(.caption2) .foregroundStyle(.secondary)++ Divider()++ Text("編碼前原始影格佇列")+ .font(.caption.weight(.semibold))+ Text("這是 byte budget 與最長停留時間,不是 RTMP 待送 bytes;下一次推流生效。")+ .font(.caption2)+ .foregroundStyle(.secondary)++ inputBufferRow("ScreenCaptureKit(主 App)",+ budget: $sckBufferMB, duration: $sckBufferDurationMS,+ budgets: [16, 24, 32, 48, 64],+ durations: [100, 150, 200, 300, 500])+ inputBufferRow("ReplayKit(擴展)",+ budget: $replayKitBufferMB, duration: $replayKitBufferDurationMS,+ budgets: [8, 12, 16, 24],+ durations: [100, 150, 200])+ }+ }++ /// 分開顯示兩種來源的 byte budget;ReplayKit 不應套用主 App 的 64 MB 上限。+ private func inputBufferRow(_ title: String, budget: Binding<Int>, duration: Binding<Int>,+ budgets: [Int], durations: [Int]) -> some View {+ VStack(alignment: .leading, spacing: 4) {+ Text(title).font(.caption)+ HStack(spacing: 12) {+ Picker("上限", selection: budget) {+ ForEach(budgets, id: \.self) { Text("\($0) MB").tag($0) }+ }+ .pickerStyle(.menu)+ Picker("停留", selection: duration) {+ ForEach(durations, id: \.self) { Text("\($0) ms").tag($0) }+ }+ .pickerStyle(.menu)+ } } } }diff --git a/liveAPP/Settings/Backup/SettingsBackupFormat.swift b/liveAPP/Settings/Backup/SettingsBackupFormat.swift--- a/liveAPP/Settings/Backup/SettingsBackupFormat.swift+++ b/liveAPP/Settings/Backup/SettingsBackupFormat.swift@@ -77,6 +77,8 @@ enum SettingsBackupRegistry { add("KeyFrameInterval", .integer(0...60)) add("BitRateMode logMode", .integer(0...2)) add("BufferCount", .integer(-1...1000))+ add("screenCaptureKitVideoBufferMB replayKitVideoBufferMB", .integer(8...64))+ add("screenCaptureKitVideoBufferDurationMS replayKitVideoBufferDurationMS", .integer(50...500)) add("Rotate LOrientation", .integer(0...360)) add("socketListenPort", .integer(1...65535)) add("directRecordingFPS recordingCompressionFPS", .integer(0...240))diff --git a/liveAPP/StreamDiagnosticsView.swift b/liveAPP/StreamDiagnosticsView.swift--- a/liveAPP/StreamDiagnosticsView.swift+++ b/liveAPP/StreamDiagnosticsView.swift@@ -167,6 +167,7 @@ struct StreamDiagnosticsSections: View { DisclosureGroup { diagnosticsSummary(value) formatSection(value)+ encoderInputSection(value) encoderSection(value) transportSection(value) timingSection(value)@@ -222,6 +223,45 @@ struct StreamDiagnosticsSections: View { } } + /// 編碼前原始影格 queue;與 VT pending、RTMP 網路待送 bytes 分開呈現。+ @ViewBuilder+ private func encoderInputSection(_ value: StreamDiagnosticsSnapshot) -> some View {+ group("編碼輸入緩衝") {+ if let input = value.encoderInput {+ detail("佇列狀態", input.availability)+ detail("容量", [+ megabytes(input.byteLimit).map { "上限 \($0)" },+ input.manualFrameLimit.map { "手動上限 \($0) frames" } ?? "自動(無手動 frame cap)",+ input.maxAge.map { "最長 \(milliseconds($0))" }+ ].compactMap { $0 }.joined(separator: " · "))+ detail("目前使用", [+ megabytes(input.bytes).map { "已用 \($0)" },+ input.queued.map { "\($0) frames" },+ megabytes(input.peakBytes).map { "峰值 \($0)" }+ ].compactMap { $0 }.joined(separator: " · "))+ detail("最舊影格", input.oldestAge.map { milliseconds($0) })+ detail("丟棄", [+ input.capacityDrops.map { "capacity \($0)" },+ input.expiredDrops.map { "expired \($0)" },+ input.oversizedDrops.map { "oversized \($0)" }+ ].compactMap { $0 }.joined(separator: " · "))+ detail("閒置", [+ input.inputIdle.map { "input \(milliseconds($0))" },+ input.outputIdle.map { "output \(milliseconds($0))" }+ ].compactMap { $0 }.joined(separator: " · "))+ Text("這是編碼前的原始影格 queue,不是 VT pending,也不是 RTMP 已編碼待送 bytes。")+ .font(.caption).foregroundStyle(.secondary)+ } else {+ Text("此快照沒有編碼輸入緩衝資料。")+ .font(.caption).foregroundStyle(.secondary)+ }+ }+ }++ private func milliseconds(_ seconds: Double) -> String {+ String(format: "%.1f ms", max(0, seconds) * 1000)+ }+ @ViewBuilder private func encoderSection(_ value: StreamDiagnosticsSnapshot) -> some View { group("VT 編碼器") {@@ -392,6 +432,10 @@ struct StreamDiagnosticsSections: View { value.map { "\($0) bytes" } } + private func megabytes(_ value: Int?) -> String? {+ value.map { String(format: "%.1f MB", Double($0) / 1_048_576) }+ }+ /// 格式來自壓縮樣本,色彩附件缺漏不以設定或 Mixer 像素格式補值。 private func encodedVideo(_ snapshot: StreamDiagnosticsSnapshot) -> String { guard let encoding = snapshot.encoding else { return "未提供編碼快照(舊版來源或無 RTMP 管線)" }diff --git a/liveAPPTests/VideoInputBufferSettingsTests.swift b/liveAPPTests/VideoInputBufferSettingsTests.swift--- /dev/null+++ b/liveAPPTests/VideoInputBufferSettingsTests.swift@@ -0,0 +1,31 @@+import Foundation+import Testing+@testable import liveAPP++struct VideoInputBufferSettingsTests {+ @Test func usesSourceSpecificDefaults() {+ let sck = VideoInputBufferSettings.load(from: nil, source: .screenCaptureKit)+ #expect(sck.budgetMB == 32)+ #expect(sck.durationMS == 150)+ #expect(sck.budgetBytes == 32 * 1024 * 1024)++ let replayKit = VideoInputBufferSettings.load(from: nil, source: .replayKit)+ #expect(replayKit.budgetMB == 15)+ #expect(replayKit.durationMS == 100)+ }++ @Test func readsAndClampsStoredValues() {+ let suite = "VideoInputBufferTests-" + UUID().uuidString+ let defaults = UserDefaults(suiteName: suite)!+ defer { defaults.removePersistentDomain(forName: suite) }+ defaults.set(20, forKey: VideoInputBufferSettings.screenCaptureKitBudgetKey)+ defaults.set(200, forKey: VideoInputBufferSettings.screenCaptureKitDurationKey)+ let value = VideoInputBufferSettings.load(from: defaults, source: .screenCaptureKit)+ #expect(value.budgetMB == 20)+ #expect(value.durationMS == 200)++ defaults.set(99, forKey: VideoInputBufferSettings.replayKitBudgetKey)+ let capped = VideoInputBufferSettings.load(from: defaults, source: .replayKit)+ #expect(capped.budgetMB == 24)+ }+}