app · e0c1d6e

Dev index Recent changes / versions Changelog About TODO

新增錄影 manifest 的來源 PTS 對照

e0c1d6e · 2026-11-12 09:40 · app
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.mdindex ffc4535..db1dffa 100644--- 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.swiftindex ced214a..4ad171b 100644--- 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.swiftindex ac3601c..10003a2 100644--- 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.swiftindex c1c48ca..953221e 100644--- 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.swiftindex 1b6d24f..f2faae7 100644--- 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.swiftindex 1e4d2f2..4f3c719 100644--- 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])