補原生錄影 frame 診斷與 inactive 事件
Docs/ChangeHistory.mdliveAPP/Capture/ScreenCaptureSource.swiftliveAPP/Capture/ScreenRecordingOrientationObserver.swiftliveAPP/liveAPPApp.swift
commit 8b0fa493c4f59620cd61fbe2f67b2a4578b62e16Author: Nuclear0709 <agp05432@gmail.com>Date: Thu Nov 12 19:54:42 2026 +0800 補原生錄影 frame 診斷與 inactive 事件 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -3,6 +3,8 @@ ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 - 錄影 manifest(sidecar)只在開始、完成或中斷等 phase 轉換時持久化 `sourceStartPTS`/`sourceEndPTS`;進度回報不把尾端累積在記憶體等待落盤,避免長錄影產生額外的狀態寫入。失敗收尾會主動釋放 `AVAssetWriter` 與檔案 URL,不讓失敗片段持有 handle。+- 原生 `SCRecordingOutput` 錄影路徑新增 `[RecordingFrame]` 診斷:由 `ScreenRecordingOrientationObserver` 統計影片 frame 數、first/last PTS、最大 gap、超過 100 ms 的 gap 與最後回呼 idle;`ScreenCaptureSource` 每 5 秒與停止時輸出。這條路徑沒有 `ScreenSamplePump`/AVLag,先前無法看出背景排程造成的影片停頓。+- `scenePhase == .inactive` 現在會寫 `[AppEnvironment] event=inactive`;先前直接 `break`,無法對照 inactive/background/active 排程。 - 錄影 manifest(sidecar)新增 `sourceAudioStartPTS`,用來量測第一個實際寫入音訊 sample 與 `sourceStartPTS`(第一個 video)的起點差;這只在 phase 轉換時落盤。 - 錄影 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` 判斷口型同步。diff --git a/liveAPP/Capture/ScreenCaptureSource.swift b/liveAPP/Capture/ScreenCaptureSource.swift--- a/liveAPP/Capture/ScreenCaptureSource.swift+++ b/liveAPP/Capture/ScreenCaptureSource.swift@@ -383,6 +383,9 @@ private final class ScreenSamplePump: NSObject, SCStreamOutput, @unchecked Senda if sourceLog { sendlog(message: "[CaptureSource] backend=screenCaptureKit session=\(token) mode=\(self.mode.rawValue) capturePhase=\(self.state.phase.rawValue) publishPhase=\(self.publishingStage) \(queues)") }+ if let observer = self.recordingOrientation {+ sendlog(message: "[RecordingFrame] session=\(token) \(observer.frameSummary(now: CMClockGetTime(CMClockGetHostTimeClock()).seconds))")+ } if let sink = self.recordingSink, detailedLog { sendlog(message: "[SegmentedRecordingQueue] \(sink.summary)") } let processingLoad = self.pump?.processing.sampleLoad() if let load = processingLoad, detailedLog {@@ -736,6 +739,9 @@ private final class ScreenSamplePump: NSObject, SCStreamOutput, @unchecked Senda /// 停止音量同步、解除來源回呼並等待取樣工作結束,再卸載探針與收尾錄製。 /// 釋放 Mixer 管線;SCStream 本身與 RTMP 連線由外層分別停止。 private func stopMediaPipeline() async {+ if let observer = recordingOrientation {+ sendlog(message: "[RecordingFrame] session=\(state.id?.uuidString ?? "unknown") final \(observer.frameSummary(now: CMClockGetTime(CMClockGetHostTimeClock()).seconds))")+ } LiveVolumeModel.shared.endCapture(state.id?.uuidString ?? "none") volumeUpdates?.cancel() await volumeUpdates?.value; volumeUpdates = nildiff --git a/liveAPP/Capture/ScreenRecordingOrientationObserver.swift b/liveAPP/Capture/ScreenRecordingOrientationObserver.swift--- a/liveAPP/Capture/ScreenRecordingOrientationObserver.swift+++ b/liveAPP/Capture/ScreenRecordingOrientationObserver.swift@@ -9,6 +9,26 @@ final class ScreenRecordingOrientationObserver: NSObject, SCStreamOutput, @unche // 只從 source 的序列 sampleQueue 存取。 private var lastOrientation: Int? private var loggedMissing = false+ private let frameLock = NSLock()+ private var frameCount = 0+ private var firstPTS: Double?+ private var lastPTS: Double?+ private var maxFrameGap: Double = 0+ private var frameGapsOver100ms = 0+ private var lastArrival: Double?++ /// 原生 SCRecordingOutput 路徑沒有 ScreenSamplePump;由這個 observer 提供+ /// 影片 frame 數與 gap,讓背景排程造成的長 gap 能在日誌中直接看見。+ func frameSummary(now: Double) -> String {+ frameLock.lock(); defer { frameLock.unlock() }+ func number(_ value: Double?) -> String {+ value.map { String(format: "%.6f", $0) } ?? "unknown"+ }+ let idle = lastArrival.map { max(0, now - $0) } ?? -1+ return "frames=\(frameCount) firstPTS=\(number(firstPTS)) lastPTS=\(number(lastPTS)) "+ + "maxGap=\(number(maxFrameGap)) gapsOver100ms=\(frameGapsOver100ms) idle=\(number(idle))"+ }+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { guard type == .screen, CMSampleBufferIsValid(sampleBuffer), CMSampleBufferDataIsReady(sampleBuffer), let image = sampleBuffer.imageBuffer else { return }@@ -17,6 +37,20 @@ final class ScreenRecordingOrientationObserver: NSObject, SCStreamOutput, @unche let raw = (values?[.videoOrientation] as? NSNumber)?.intValue ?? (CMGetAttachment(sampleBuffer, key: SCStreamFrameInfo.videoOrientation.rawValue as CFString, attachmentModeOut: nil) as? NSNumber)?.intValue let seconds = sampleBuffer.presentationTimeStamp.seconds+ let arrival = CMClockGetTime(CMClockGetHostTimeClock()).seconds+ if seconds.isFinite, seconds > 0 {+ frameLock.lock()+ frameCount += 1+ if firstPTS == nil { firstPTS = seconds }+ if let previous = lastPTS {+ let gap = seconds - previous+ if gap > maxFrameGap { maxFrameGap = gap }+ if gap > 0.100 { frameGapsOver100ms += 1 }+ }+ lastPTS = seconds+ if arrival.isFinite { lastArrival = arrival }+ frameLock.unlock()+ } timeline.observe(seconds: seconds, orientation: raw) if raw != lastOrientation || (raw == nil && !loggedMissing) { sendlog(message: "[RecordingOrientation] exif=\(raw.map(String.init) ?? "missing") pixel=\(CVPixelBufferGetWidth(image))x\(CVPixelBufferGetHeight(image)) pts=\(seconds)")diff --git a/liveAPP/liveAPPApp.swift b/liveAPP/liveAPPApp.swift--- a/liveAPP/liveAPPApp.swift+++ b/liveAPP/liveAPPApp.swift@@ -1386,7 +1386,8 @@ AVCaptureDevice.requestAccess(for: .audio) { granted in .onChange(of: scenePhase) { phase in switch phase { case .inactive:- break+ // 面板、切換器等中斷前景但仍可見;保留事件供背景/PiP 排程對照。+ logAppEnvironment("inactive") case .background: AppSession.markBackground()