diff --git a/Screen-Remote/Services/Scrcpy/Video/ScrcpyFrameGenerationGate.swift b/Screen-Remote/Services/Scrcpy/Video/ScrcpyFrameGenerationGate.swift new file mode 100644 index 0000000..11c4b9f --- /dev/null +++ b/Screen-Remote/Services/Scrcpy/Video/ScrcpyFrameGenerationGate.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Prevents frames from a previous scrcpy session from being rendered after +/// reconnects or parameter-driven session replacement. +final class ScrcpyFrameGenerationGate { + private let lock = NSLock() + private var generation: UInt64 = 0 + + func beginSession() -> UInt64 { + lock.lock() + defer { lock.unlock() } + generation &+= 1 + return generation + } + + func invalidate() { + lock.lock() + generation &+= 1 + lock.unlock() + } + + func accepts(_ frameGeneration: UInt64) -> Bool { + lock.lock() + defer { lock.unlock() } + return frameGeneration == generation + } + + var currentGeneration: UInt64 { + lock.lock() + defer { lock.unlock() } + return generation + } +} diff --git a/Screen-Remote/Services/Scrcpy/Video/ScrcpyStreamingFrame.swift b/Screen-Remote/Services/Scrcpy/Video/ScrcpyStreamingFrame.swift new file mode 100644 index 0000000..74afb14 --- /dev/null +++ b/Screen-Remote/Services/Scrcpy/Video/ScrcpyStreamingFrame.swift @@ -0,0 +1,23 @@ +import CoreVideo +import Foundation + +/// A transport object between the native scrcpy decoder and the macOS rendering layer. +/// +/// The previous rendering path converted every decoded frame directly into CGImage. +/// Keeping the decoded CVPixelBuffer alive allows future Metal-backed surfaces to consume +/// frames without forcing an intermediate image conversion. +final class ScrcpyStreamingFrame: @unchecked Sendable { + let pixelBuffer: CVPixelBuffer + let timestamp: UInt64 + let generation: UInt64 + + init( + pixelBuffer: CVPixelBuffer, + timestamp: UInt64, + generation: UInt64 + ) { + self.pixelBuffer = pixelBuffer + self.timestamp = timestamp + self.generation = generation + } +} diff --git a/Screen-Remote/Services/Scrcpy/Video/ScrcpyVideoSurface.swift b/Screen-Remote/Services/Scrcpy/Video/ScrcpyVideoSurface.swift index 6e858e7..adb8adf 100644 --- a/Screen-Remote/Services/Scrcpy/Video/ScrcpyVideoSurface.swift +++ b/Screen-Remote/Services/Scrcpy/Video/ScrcpyVideoSurface.swift @@ -6,12 +6,28 @@ import VideoToolbox import Darwin final class ScrcpyVideoSurface: ObservableObject { + /// Compatibility image output for existing SwiftUI views. + /// New renderers should consume `frame` and keep the original pixel buffer. @Published private(set) var image: CGImage? + @Published private(set) var frame: ScrcpyStreamingFrame? @Published private(set) var pixelSize = CGSize.zero private let context = CIContext(options: [.cacheIntermediates: false]) + private var generation: UInt64 = 0 func publish(_ pixelBuffer: CVPixelBuffer) { + generation &+= 1 + let streamingFrame = ScrcpyStreamingFrame( + pixelBuffer: pixelBuffer, + generation: generation + ) + + DispatchQueue.main.async { [weak self] in + self?.frame = streamingFrame + } + + // Keep the existing CGImage path temporarily so current SwiftUI + // surfaces continue to work while Metal-backed rendering is introduced. let ciImage = CIImage(cvPixelBuffer: pixelBuffer) guard let image = context.createCGImage(ciImage, from: ciImage.extent) else { return } DispatchQueue.main.async { [weak self] in @@ -23,6 +39,7 @@ final class ScrcpyVideoSurface: ObservableObject { func clear() { DispatchQueue.main.async { [weak self] in self?.image = nil + self?.frame = nil self?.pixelSize = .zero } }