@livedesk/client 0.1.71 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ import CoreFoundation
2
+ import CoreGraphics
3
+ import CoreMedia
4
+ import CoreVideo
5
+ import Foundation
6
+ import ScreenCaptureKit
7
+ import VideoToolbox
8
+
9
+ @available(macOS 12.3, *)
10
+ final class LiveDeskScreenCaptureKitEncoder: NSObject, SCStreamOutput, SCStreamDelegate {
11
+ private let displayIndex: Int
12
+ private let width: Int
13
+ private let height: Int
14
+ private let fps: Int
15
+ private let queue = DispatchQueue(label: "dev.livedesk.screencapturekit.frames")
16
+ private let writeLock = NSLock()
17
+ private var stream: SCStream?
18
+ private var compressionSession: VTCompressionSession?
19
+ private var frameCount: Int = 0
20
+ private var encodedCount: Int = 0
21
+
22
+ init(displayIndex: Int, width: Int, height: Int, fps: Int) {
23
+ self.displayIndex = max(0, displayIndex)
24
+ self.width = max(2, width)
25
+ self.height = max(2, height)
26
+ self.fps = max(1, fps)
27
+ super.init()
28
+ }
29
+
30
+ func start() async throws {
31
+ try createCompressionSession()
32
+
33
+ let shareableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
34
+ guard !shareableContent.displays.isEmpty else {
35
+ throw LiveDeskSCKError.noDisplays
36
+ }
37
+
38
+ let display = shareableContent.displays[min(displayIndex, shareableContent.displays.count - 1)]
39
+ let filter = SCContentFilter(display: display, excludingWindows: [])
40
+ let configuration = SCStreamConfiguration()
41
+ configuration.width = width
42
+ configuration.height = height
43
+ configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
44
+ configuration.queueDepth = 3
45
+ configuration.showsCursor = false
46
+ configuration.pixelFormat = kCVPixelFormatType_32BGRA
47
+
48
+ let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
49
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
50
+ self.stream = stream
51
+ try await stream.startCapture()
52
+
53
+ fputs("LiveDeskSCK started displayIndex=\(displayIndex) displayID=\(display.displayID) size=\(width)x\(height) fps=\(fps)\n", stderr)
54
+ RunLoop.main.run()
55
+ }
56
+
57
+ private func createCompressionSession() throws {
58
+ var session: VTCompressionSession?
59
+ let status = VTCompressionSessionCreate(
60
+ allocator: kCFAllocatorDefault,
61
+ width: Int32(width),
62
+ height: Int32(height),
63
+ codecType: kCMVideoCodecType_H264,
64
+ encoderSpecification: nil,
65
+ imageBufferAttributes: nil,
66
+ compressedDataAllocator: nil,
67
+ outputCallback: Self.compressionCallback,
68
+ refcon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
69
+ compressionSessionOut: &session)
70
+
71
+ guard status == noErr, let session else {
72
+ throw LiveDeskSCKError.encoderCreate(status)
73
+ }
74
+
75
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
76
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
77
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Baseline_AutoLevel)
78
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: fps))
79
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, value: NSNumber(value: 1))
80
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: fps))
81
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: max(2_500_000, width * height * fps / 6)))
82
+ VTCompressionSessionPrepareToEncodeFrames(session)
83
+ compressionSession = session
84
+ }
85
+
86
+ private static let compressionCallback: VTCompressionOutputCallback = { refCon, _, status, _, sampleBuffer in
87
+ guard status == noErr,
88
+ let refCon,
89
+ let sampleBuffer,
90
+ CMSampleBufferDataIsReady(sampleBuffer) else {
91
+ return
92
+ }
93
+
94
+ let encoder = Unmanaged<LiveDeskScreenCaptureKitEncoder>.fromOpaque(refCon).takeUnretainedValue()
95
+ encoder.writeEncodedSampleBuffer(sampleBuffer)
96
+ }
97
+
98
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
99
+ guard type == .screen,
100
+ CMSampleBufferDataIsReady(sampleBuffer),
101
+ isCompleteFrame(sampleBuffer),
102
+ let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
103
+ let session = compressionSession else {
104
+ return
105
+ }
106
+
107
+ frameCount += 1
108
+ let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
109
+ let duration = CMTime(value: 1, timescale: CMTimeScale(fps))
110
+ let status = VTCompressionSessionEncodeFrame(
111
+ session,
112
+ imageBuffer: imageBuffer,
113
+ presentationTimeStamp: pts,
114
+ duration: duration,
115
+ frameProperties: nil,
116
+ sourceFrameRefcon: nil,
117
+ infoFlagsOut: nil)
118
+
119
+ if status != noErr && (frameCount == 1 || frameCount % 120 == 0) {
120
+ fputs("LiveDeskSCK encode status=\(status) frames=\(frameCount)\n", stderr)
121
+ }
122
+ }
123
+
124
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
125
+ fputs("LiveDeskSCK stream stopped: \(error)\n", stderr)
126
+ exit(3)
127
+ }
128
+
129
+ private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
130
+ guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as NSArray?,
131
+ let first = attachments.firstObject as? NSDictionary,
132
+ let rawStatus = first[SCStreamFrameInfo.status] as? Int else {
133
+ return true
134
+ }
135
+ return rawStatus == SCFrameStatus.complete.rawValue
136
+ }
137
+
138
+ private func writeEncodedSampleBuffer(_ sampleBuffer: CMSampleBuffer) {
139
+ guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else {
140
+ return
141
+ }
142
+
143
+ var output = Data()
144
+ if isKeyFrame(sampleBuffer), let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) {
145
+ appendParameterSet(formatDescription, index: 0, to: &output)
146
+ appendParameterSet(formatDescription, index: 1, to: &output)
147
+ }
148
+
149
+ var lengthAtOffset = 0
150
+ var totalLength = 0
151
+ var dataPointer: UnsafeMutablePointer<Int8>?
152
+ let status = CMBlockBufferGetDataPointer(
153
+ blockBuffer,
154
+ atOffset: 0,
155
+ lengthAtOffsetOut: &lengthAtOffset,
156
+ totalLengthOut: &totalLength,
157
+ dataPointerOut: &dataPointer)
158
+
159
+ guard status == noErr, let dataPointer, totalLength > 4 else {
160
+ return
161
+ }
162
+
163
+ var offset = 0
164
+ while offset + 4 <= totalLength {
165
+ var nalLength: UInt32 = 0
166
+ memcpy(&nalLength, dataPointer.advanced(by: offset), 4)
167
+ nalLength = CFSwapInt32BigToHost(nalLength)
168
+ let length = Int(nalLength)
169
+ if length <= 0 || offset + 4 + length > totalLength {
170
+ break
171
+ }
172
+
173
+ appendStartCode(to: &output)
174
+ output.append(UnsafeRawPointer(dataPointer.advanced(by: offset + 4)).assumingMemoryBound(to: UInt8.self), count: length)
175
+ offset += 4 + length
176
+ }
177
+
178
+ guard !output.isEmpty else {
179
+ return
180
+ }
181
+
182
+ writeLock.lock()
183
+ FileHandle.standardOutput.write(output)
184
+ writeLock.unlock()
185
+
186
+ encodedCount += 1
187
+ if encodedCount == 1 || encodedCount % max(1, fps * 5) == 0 {
188
+ fputs("LiveDeskSCK encoded frames=\(encodedCount) bytes=\(output.count)\n", stderr)
189
+ }
190
+ }
191
+
192
+ private func isKeyFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
193
+ guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as NSArray?,
194
+ let first = attachments.firstObject as? NSDictionary else {
195
+ return true
196
+ }
197
+ return !(first[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
198
+ }
199
+
200
+ private func appendParameterSet(_ formatDescription: CMFormatDescription, index: Int, to output: inout Data) {
201
+ var pointer: UnsafePointer<UInt8>?
202
+ var size = 0
203
+ var count = 0
204
+ var nalHeaderLength: Int32 = 0
205
+ let status = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
206
+ formatDescription,
207
+ parameterSetIndex: index,
208
+ parameterSetPointerOut: &pointer,
209
+ parameterSetSizeOut: &size,
210
+ parameterSetCountOut: &count,
211
+ nalUnitHeaderLengthOut: &nalHeaderLength)
212
+
213
+ guard status == noErr, let pointer, size > 0 else {
214
+ return
215
+ }
216
+
217
+ appendStartCode(to: &output)
218
+ output.append(pointer, count: size)
219
+ }
220
+
221
+ private func appendStartCode(to output: inout Data) {
222
+ output.append(contentsOf: [0, 0, 0, 1])
223
+ }
224
+ }
225
+
226
+ enum LiveDeskSCKError: Error {
227
+ case noDisplays
228
+ case encoderCreate(OSStatus)
229
+ }
230
+
231
+ func intArg(_ name: String, default defaultValue: Int) -> Int {
232
+ let args = CommandLine.arguments
233
+ guard let index = args.firstIndex(of: name), index + 1 < args.count else {
234
+ return defaultValue
235
+ }
236
+ return Int(args[index + 1]) ?? defaultValue
237
+ }
238
+
239
+ @main
240
+ struct LiveDeskSCKMain {
241
+ static func main() async {
242
+ guard #available(macOS 12.3, *) else {
243
+ fputs("LiveDeskSCK requires macOS 12.3 or later.\n", stderr)
244
+ exit(2)
245
+ }
246
+
247
+ let displayIndex = intArg("--display-index", default: 0)
248
+ let width = intArg("--width", default: 960)
249
+ let height = intArg("--height", default: 540)
250
+ let fps = intArg("--fps", default: 30)
251
+
252
+ do {
253
+ let encoder = LiveDeskScreenCaptureKitEncoder(displayIndex: displayIndex, width: width, height: height, fps: fps)
254
+ try await encoder.start()
255
+ } catch {
256
+ fputs("LiveDeskSCK failed: \(error)\n", stderr)
257
+ exit(1)
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,260 @@
1
+ import CoreFoundation
2
+ import CoreGraphics
3
+ import CoreMedia
4
+ import CoreVideo
5
+ import Foundation
6
+ import ScreenCaptureKit
7
+ import VideoToolbox
8
+
9
+ @available(macOS 12.3, *)
10
+ final class LiveDeskScreenCaptureKitEncoder: NSObject, SCStreamOutput, SCStreamDelegate {
11
+ private let displayIndex: Int
12
+ private let width: Int
13
+ private let height: Int
14
+ private let fps: Int
15
+ private let queue = DispatchQueue(label: "dev.livedesk.screencapturekit.frames")
16
+ private let writeLock = NSLock()
17
+ private var stream: SCStream?
18
+ private var compressionSession: VTCompressionSession?
19
+ private var frameCount: Int = 0
20
+ private var encodedCount: Int = 0
21
+
22
+ init(displayIndex: Int, width: Int, height: Int, fps: Int) {
23
+ self.displayIndex = max(0, displayIndex)
24
+ self.width = max(2, width)
25
+ self.height = max(2, height)
26
+ self.fps = max(1, fps)
27
+ super.init()
28
+ }
29
+
30
+ func start() async throws {
31
+ try createCompressionSession()
32
+
33
+ let shareableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
34
+ guard !shareableContent.displays.isEmpty else {
35
+ throw LiveDeskSCKError.noDisplays
36
+ }
37
+
38
+ let display = shareableContent.displays[min(displayIndex, shareableContent.displays.count - 1)]
39
+ let filter = SCContentFilter(display: display, excludingWindows: [])
40
+ let configuration = SCStreamConfiguration()
41
+ configuration.width = width
42
+ configuration.height = height
43
+ configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
44
+ configuration.queueDepth = 3
45
+ configuration.showsCursor = false
46
+ configuration.pixelFormat = kCVPixelFormatType_32BGRA
47
+
48
+ let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
49
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
50
+ self.stream = stream
51
+ try await stream.startCapture()
52
+
53
+ fputs("LiveDeskSCK started displayIndex=\(displayIndex) displayID=\(display.displayID) size=\(width)x\(height) fps=\(fps)\n", stderr)
54
+ RunLoop.main.run()
55
+ }
56
+
57
+ private func createCompressionSession() throws {
58
+ var session: VTCompressionSession?
59
+ let status = VTCompressionSessionCreate(
60
+ allocator: kCFAllocatorDefault,
61
+ width: Int32(width),
62
+ height: Int32(height),
63
+ codecType: kCMVideoCodecType_H264,
64
+ encoderSpecification: nil,
65
+ imageBufferAttributes: nil,
66
+ compressedDataAllocator: nil,
67
+ outputCallback: Self.compressionCallback,
68
+ refcon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
69
+ compressionSessionOut: &session)
70
+
71
+ guard status == noErr, let session else {
72
+ throw LiveDeskSCKError.encoderCreate(status)
73
+ }
74
+
75
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
76
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
77
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Baseline_AutoLevel)
78
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: fps))
79
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, value: NSNumber(value: 1))
80
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: NSNumber(value: fps))
81
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: max(2_500_000, width * height * fps / 6)))
82
+ VTCompressionSessionPrepareToEncodeFrames(session)
83
+ compressionSession = session
84
+ }
85
+
86
+ private static let compressionCallback: VTCompressionOutputCallback = { refCon, _, status, _, sampleBuffer in
87
+ guard status == noErr,
88
+ let refCon,
89
+ let sampleBuffer,
90
+ CMSampleBufferDataIsReady(sampleBuffer) else {
91
+ return
92
+ }
93
+
94
+ let encoder = Unmanaged<LiveDeskScreenCaptureKitEncoder>.fromOpaque(refCon).takeUnretainedValue()
95
+ encoder.writeEncodedSampleBuffer(sampleBuffer)
96
+ }
97
+
98
+ func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
99
+ guard type == .screen,
100
+ CMSampleBufferDataIsReady(sampleBuffer),
101
+ isCompleteFrame(sampleBuffer),
102
+ let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
103
+ let session = compressionSession else {
104
+ return
105
+ }
106
+
107
+ frameCount += 1
108
+ let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
109
+ let duration = CMTime(value: 1, timescale: CMTimeScale(fps))
110
+ let status = VTCompressionSessionEncodeFrame(
111
+ session,
112
+ imageBuffer: imageBuffer,
113
+ presentationTimeStamp: pts,
114
+ duration: duration,
115
+ frameProperties: nil,
116
+ sourceFrameRefcon: nil,
117
+ infoFlagsOut: nil)
118
+
119
+ if status != noErr && (frameCount == 1 || frameCount % 120 == 0) {
120
+ fputs("LiveDeskSCK encode status=\(status) frames=\(frameCount)\n", stderr)
121
+ }
122
+ }
123
+
124
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
125
+ fputs("LiveDeskSCK stream stopped: \(error)\n", stderr)
126
+ exit(3)
127
+ }
128
+
129
+ private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
130
+ guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as NSArray?,
131
+ let first = attachments.firstObject as? NSDictionary,
132
+ let rawStatus = first[SCStreamFrameInfo.status] as? Int else {
133
+ return true
134
+ }
135
+ return rawStatus == SCFrameStatus.complete.rawValue
136
+ }
137
+
138
+ private func writeEncodedSampleBuffer(_ sampleBuffer: CMSampleBuffer) {
139
+ guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else {
140
+ return
141
+ }
142
+
143
+ var output = Data()
144
+ if isKeyFrame(sampleBuffer), let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) {
145
+ appendParameterSet(formatDescription, index: 0, to: &output)
146
+ appendParameterSet(formatDescription, index: 1, to: &output)
147
+ }
148
+
149
+ var lengthAtOffset = 0
150
+ var totalLength = 0
151
+ var dataPointer: UnsafeMutablePointer<Int8>?
152
+ let status = CMBlockBufferGetDataPointer(
153
+ blockBuffer,
154
+ atOffset: 0,
155
+ lengthAtOffsetOut: &lengthAtOffset,
156
+ totalLengthOut: &totalLength,
157
+ dataPointerOut: &dataPointer)
158
+
159
+ guard status == noErr, let dataPointer, totalLength > 4 else {
160
+ return
161
+ }
162
+
163
+ var offset = 0
164
+ while offset + 4 <= totalLength {
165
+ var nalLength: UInt32 = 0
166
+ memcpy(&nalLength, dataPointer.advanced(by: offset), 4)
167
+ nalLength = CFSwapInt32BigToHost(nalLength)
168
+ let length = Int(nalLength)
169
+ if length <= 0 || offset + 4 + length > totalLength {
170
+ break
171
+ }
172
+
173
+ appendStartCode(to: &output)
174
+ output.append(UnsafeRawPointer(dataPointer.advanced(by: offset + 4)).assumingMemoryBound(to: UInt8.self), count: length)
175
+ offset += 4 + length
176
+ }
177
+
178
+ guard !output.isEmpty else {
179
+ return
180
+ }
181
+
182
+ writeLock.lock()
183
+ FileHandle.standardOutput.write(output)
184
+ writeLock.unlock()
185
+
186
+ encodedCount += 1
187
+ if encodedCount == 1 || encodedCount % max(1, fps * 5) == 0 {
188
+ fputs("LiveDeskSCK encoded frames=\(encodedCount) bytes=\(output.count)\n", stderr)
189
+ }
190
+ }
191
+
192
+ private func isKeyFrame(_ sampleBuffer: CMSampleBuffer) -> Bool {
193
+ guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as NSArray?,
194
+ let first = attachments.firstObject as? NSDictionary else {
195
+ return true
196
+ }
197
+ return !(first[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
198
+ }
199
+
200
+ private func appendParameterSet(_ formatDescription: CMFormatDescription, index: Int, to output: inout Data) {
201
+ var pointer: UnsafePointer<UInt8>?
202
+ var size = 0
203
+ var count = 0
204
+ var nalHeaderLength: Int32 = 0
205
+ let status = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
206
+ formatDescription,
207
+ parameterSetIndex: index,
208
+ parameterSetPointerOut: &pointer,
209
+ parameterSetSizeOut: &size,
210
+ parameterSetCountOut: &count,
211
+ nalUnitHeaderLengthOut: &nalHeaderLength)
212
+
213
+ guard status == noErr, let pointer, size > 0 else {
214
+ return
215
+ }
216
+
217
+ appendStartCode(to: &output)
218
+ output.append(pointer, count: size)
219
+ }
220
+
221
+ private func appendStartCode(to output: inout Data) {
222
+ output.append(contentsOf: [0, 0, 0, 1])
223
+ }
224
+ }
225
+
226
+ enum LiveDeskSCKError: Error {
227
+ case noDisplays
228
+ case encoderCreate(OSStatus)
229
+ }
230
+
231
+ func intArg(_ name: String, default defaultValue: Int) -> Int {
232
+ let args = CommandLine.arguments
233
+ guard let index = args.firstIndex(of: name), index + 1 < args.count else {
234
+ return defaultValue
235
+ }
236
+ return Int(args[index + 1]) ?? defaultValue
237
+ }
238
+
239
+ @main
240
+ struct LiveDeskSCKMain {
241
+ static func main() async {
242
+ guard #available(macOS 12.3, *) else {
243
+ fputs("LiveDeskSCK requires macOS 12.3 or later.\n", stderr)
244
+ exit(2)
245
+ }
246
+
247
+ let displayIndex = intArg("--display-index", default: 0)
248
+ let width = intArg("--width", default: 960)
249
+ let height = intArg("--height", default: 540)
250
+ let fps = intArg("--fps", default: 30)
251
+
252
+ do {
253
+ let encoder = LiveDeskScreenCaptureKitEncoder(displayIndex: displayIndex, width: width, height: height, fps: fps)
254
+ try await encoder.start()
255
+ } catch {
256
+ fputs("LiveDeskSCK failed: \(error)\n", stderr)
257
+ exit(1)
258
+ }
259
+ }
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {