新增錄影 manifest 的來源 PTS 對照
Docs/ChangeHistory.mdliveAPP/Capture/CaptureSessionState.swiftliveAPP/Capture/RecordingLibrary.swiftliveAPP/Capture/SegmentedRecordingWriter.swiftliveAPPTests/RecordingLibraryTests.swiftliveAPPTests/SegmentedRecordingTests.swift
commit e0c1d6e2996c6100b838428d81fdc6bb756eb020Author: Nuclear0709 <agp05432@gmail.com>Date: Thu Nov 12 09:40:56 2026 +0800 新增錄影 manifest 的來源 PTS 對照 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 診斷可讀性 +- 錄影 manifest(sidecar)新增 `sourceStartPTS`/`sourceEndPTS`/`fileStartPTS`,由 `SegmentedRecordingWriter` 在第一個 video sample 與完成時寫入;`filePTS = sourcePTS - sourceStartPTS + fileStartPTS` 可對照 App 擷取時鐘與檔案時間軸。此階段只建立對照,不改檔案時間戳。 - A/V Lag 新增 `sourcePTSDeltaDrift`/`sourcePTSDeltaPerMinute`/`avClockDrift`/`avSyncState`,以第一筆有效來源差為基準區分 stable/drifting/jumped;報告頁與 JSON 都能看到漂移趨勢,不直接用 `publish throughput avOffset` 判斷口型同步。 - 編碼輸入緩衝 UI 改稱「佇列保留」,新增 `received`/`consumed` 累計交接數;文件明確說明直接 handoff 時 `bytes=0`、`peakBytes=0` 是正常,不代表影格沒有進入編碼,也不代表沒有配置容量。 - TTS 最小朗讀字數預設改為 5,並把 UI 的 `@AppStorage` 與 `TTSService` 的 fallback 統一由 `TTSDefaults` 提供,避免兩邊預設值不同。最小/最大字數、佇列上限與溢出策略現在變更時立即 `updateDefault()`,不再只依賴離開設定頁或按「套用設定」才生效;`[TTS]` 同步日誌會輸出目前生效的 `minLen`/`maxLen`/`maxQueue`。diff --git a/liveAPP/Capture/CaptureSessionState.swift b/liveAPP/Capture/CaptureSessionState.swift--- a/liveAPP/Capture/CaptureSessionState.swift+++ b/liveAPP/Capture/CaptureSessionState.swift@@ -92,6 +92,13 @@ struct LocalRecording: Identifiable, Codable { /// 同一場可控錄製的群組與片段序號,舊紀錄為 nil。 var segmentGroup: UUID? var segmentNumber: Int?+ /// 本段第一個 video source PTS;舊紀錄或未開始寫入時為 nil。+ /// 這是 App 擷取時間軸,不是 MP4 的檔內 PTS。+ var sourceStartPTS: Double?+ /// 本段最後成功寫入的 source PTS(影片與音訊尾端取較大者)。+ var sourceEndPTS: Double?+ /// MP4 檔內第一個可播放 sample 的 PTS;正常由 writer 設為 0。+ var fileStartPTS: Double? /// 合併產物所屬群組;與原始片段分開保存,不參與再次合併。 var mergedFromGroup: UUID? /// 轉碼暫存檔大小;完成前不可當作可播放產物。diff --git a/liveAPP/Capture/RecordingLibrary.swift b/liveAPP/Capture/RecordingLibrary.swift--- a/liveAPP/Capture/RecordingLibrary.swift+++ b/liveAPP/Capture/RecordingLibrary.swift@@ -82,6 +82,25 @@ import Photos do { try persist(recordings[index]) } catch { errorMessage = "片段索引無法保存。" } }+ /// 寫入此片段與 App 擷取時間軸的對照;manifest JSON 就是 sidecar。+ /// sourceStartPTS 是本段第一個 video PTS,fileStartPTS 正常為 0。+ func recordSourceRange(_ id: UUID, sourceStartPTS: Double?, sourceEndPTS: Double?, persist: Bool = true) {+ guard let index = recordings.firstIndex(where: { $0.id == id }) else { return }+ var item = recordings[index]+ if let sourceStartPTS, item.sourceStartPTS == nil {+ item.sourceStartPTS = sourceStartPTS+ item.fileStartPTS = item.fileStartPTS ?? 0+ }+ if let sourceEndPTS, sourceEndPTS.isFinite {+ item.sourceEndPTS = max(item.sourceEndPTS ?? sourceEndPTS, sourceEndPTS)+ }+ recordings[index] = item+ // 進度更新只留在記憶體;phase 轉換時才落盤,避免每秒寫 manifest。+ guard persist else { return }+ do { try persist(item) }+ catch { errorMessage = "錄影時間軸無法保存。" }+ }+ /// 只允許使用者驗證中斷片段;可讀到有效影像才開放播放與匯出。 func recover(_ item: LocalRecording) async throws { guard item.phase == .interrupted else { return }diff --git a/liveAPP/Capture/SegmentedRecordingWriter.swift b/liveAPP/Capture/SegmentedRecordingWriter.swift--- a/liveAPP/Capture/SegmentedRecordingWriter.swift+++ b/liveAPP/Capture/SegmentedRecordingWriter.swift@@ -341,6 +341,12 @@ actor RecordingSegmentWriter { let sizeWarning = sizeReadError == nil ? "" : " · 大小讀取失敗(目前大小未確認)" let duration = completedDuration ?? (lastVideo.isValid ? max(0, lastVideo.seconds - start.seconds) : 0) let bitrate = settings.automaticBitrate ? "編碼器自動碼率" : "目標 \(settings.encoding.megabits) Mbps"+ let sourceStart = start.isValid ? start.seconds : nil+ var sourceEnd: Double?+ if lastVideo.isValid { sourceEnd = lastVideo.seconds }+ if audioEnd.isValid { sourceEnd = max(sourceEnd ?? audioEnd.seconds, audioEnd.seconds) }+ await library.recordSourceRange(id, sourceStartPTS: sourceStart, sourceEndPTS: sourceEnd,+ persist: phase != nil) await library.update(id, phase: phase, duration: duration, bytes: bytes, message: "可控錄製 · 第 \(number) 段 · \(bitrate) · 寫入端丟棄 \(dropped) 個樣本" + sizeWarning) }diff --git a/liveAPPTests/RecordingLibraryTests.swift b/liveAPPTests/RecordingLibraryTests.swift--- a/liveAPPTests/RecordingLibraryTests.swift+++ b/liveAPPTests/RecordingLibraryTests.swift@@ -39,6 +39,19 @@ import Testing #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(RecordingLibrary(directory: directory).recordings.isEmpty) }+ @Test func sourceRangePersistsForFileMapping() throws {+ let directory = temporaryDirectory()+ defer { try? FileManager.default.removeItem(at: directory) }+ let library = RecordingLibrary(directory: directory)+ let item = try library.create()+ library.recordSourceRange(item.id, sourceStartPTS: 215880.58, sourceEndPTS: 215940.58)+ let reopened = RecordingLibrary(directory: directory)+ let restored = reopened.recordings.first { $0.id == item.id }+ #expect(restored?.sourceStartPTS == 215880.58)+ #expect(restored?.sourceEndPTS == 215940.58)+ #expect(restored?.fileStartPTS == 0)+ }+ @Test func malformedManifestDoesNotHideOtherRecordings() throws { let directory = temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) }diff --git a/liveAPPTests/SegmentedRecordingTests.swift b/liveAPPTests/SegmentedRecordingTests.swift--- a/liveAPPTests/SegmentedRecordingTests.swift+++ b/liveAPPTests/SegmentedRecordingTests.swift@@ -50,6 +50,9 @@ import Testing let actualBytes = try #require((attributes[.size] as? NSNumber)?.int64Value) #expect(actualBytes > 0, Comment(rawValue: context)) #expect(item.bytes == actualBytes, Comment(rawValue: context))+ #expect(item.sourceStartPTS != nil, Comment(rawValue: context))+ #expect(item.sourceEndPTS != nil, Comment(rawValue: context))+ #expect(item.fileStartPTS == 0, Comment(rawValue: context)) } #expect(Set(library.recordings.compactMap(\.segmentGroup)).count == 1) #expect(Set(library.recordings.compactMap(\.segmentNumber)) == [1, 2])