app · 8b0fa49

Dev index Recent changes / versions Changelog About TODO

補原生錄影 frame 診斷與 inactive 事件

8b0fa49 · 2026-11-12 19:54 · app
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.mdindex f3933d9..db4b85e 100644--- 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.swiftindex 1d67458..9a2e186 100644--- 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.swiftindex a6b7a58..2844a09 100644--- 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.swiftindex 41d4560..4c41ddb 100644--- 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()