@stim-cli/server 1.10.1 → 1.12.0

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,282 @@
1
+ import Accelerate
2
+ import CoreMedia
3
+ import CoreVideo
4
+ import Foundation
5
+ import VideoToolbox
6
+
7
+ /// One encoded H.264 access unit in Annex-B form. A keyframe starts with its SPS and PPS.
8
+ struct AccessUnit {
9
+ var data: Data
10
+ var keyframe: Bool
11
+ var capturedAt: Double
12
+ var width: Int
13
+ var height: Int
14
+ }
15
+
16
+ /// A low-latency hardware H.264 encoder: real time, no frame reordering, a keyframe at least every 2 s.
17
+ /// Each source frame is copied, rotated upright and scaled to fit `maxEdge` before encoding, so a source
18
+ /// surface the simulator keeps drawing into is never read after `encode` returns.
19
+ final class VideoEncoder {
20
+ static let minBitrate = 250_000
21
+ static let maxBitrate = 8_000_000
22
+
23
+ private let queue = DispatchQueue(label: "stim.video.encode")
24
+ private let output: (AccessUnit) -> Void
25
+ private var session: VTCompressionSession?
26
+ private var transfer: VTPixelTransferSession?
27
+ private var rotation: VTPixelRotationSession?
28
+ private var rotated: CVPixelBufferPool?
29
+ private var rotatedSize = (width: 0, height: 0)
30
+ private var size = (width: 0, height: 0)
31
+ private var forceKeyframe = true
32
+ private var lastTimestamp = CMTime.invalid
33
+
34
+ private var maxEdge: Int
35
+ private var fps: Int
36
+ private var bitrate: Int
37
+ private var bgra: CVPixelBufferPool?
38
+ private var bgraSize = (width: 0, height: 0)
39
+
40
+ init(maxEdge: Int, fps: Int, bitrate: Int, output: @escaping (AccessUnit) -> Void) {
41
+ self.maxEdge = maxEdge
42
+ self.fps = fps
43
+ self.bitrate = bitrate
44
+ self.output = output
45
+ }
46
+
47
+ deinit {
48
+ if let session { VTCompressionSessionInvalidate(session) }
49
+ }
50
+
51
+ /// A disabled encoder drops its session and pools; the next frame after enabling starts with a keyframe.
52
+ func configure(enabled: Bool, maxEdge: Int, fps: Int, bitrate: Int) {
53
+ queue.async {
54
+ if !enabled {
55
+ self.invalidate()
56
+ self.transfer = nil
57
+ self.rotation = nil
58
+ self.rotated = nil
59
+ self.bgra = nil
60
+ }
61
+ self.maxEdge = maxEdge
62
+ if self.fps != fps, let session = self.session {
63
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, value: fps as CFNumber)
64
+ }
65
+ self.fps = fps
66
+ let clamped = min(max(bitrate, Self.minBitrate), Self.maxBitrate)
67
+ if self.bitrate != clamped, let session = self.session { Self.applyBitrate(session, clamped) }
68
+ self.bitrate = clamped
69
+ }
70
+ }
71
+
72
+ func requestKeyframe() {
73
+ queue.async { self.forceKeyframe = true }
74
+ }
75
+
76
+ /// `quarterTurns` counter-clockwise quarter turns make the source upright.
77
+ func encode(_ source: CVPixelBuffer, quarterTurns: Int, capturedAt: Double) {
78
+ queue.sync { self.encodeOnQueue(source, quarterTurns: quarterTurns, capturedAt: capturedAt) }
79
+ }
80
+
81
+ /// Encodes an upright RGBA8888 image, as the emulator's `streamScreenshot` sends it. CoreVideo has no
82
+ /// RGBA pixel format, so the bytes are swizzled into a BGRA buffer.
83
+ func encode(rgba: Data, width: Int, height: Int, capturedAt: Double) {
84
+ queue.sync {
85
+ if bgra == nil || bgraSize != (width, height) {
86
+ let attributes: [CFString: Any] = [
87
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
88
+ kCVPixelBufferWidthKey: width,
89
+ kCVPixelBufferHeightKey: height,
90
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
91
+ ]
92
+ bgra = nil
93
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &bgra)
94
+ bgraSize = (width, height)
95
+ }
96
+ var buffer: CVPixelBuffer?
97
+ guard rgba.count == width * height * 4, let bgra,
98
+ CVPixelBufferPoolCreatePixelBuffer(nil, bgra, &buffer) == kCVReturnSuccess, let buffer
99
+ else { return }
100
+ CVPixelBufferLockBaseAddress(buffer, [])
101
+ let swizzled = rgba.withUnsafeBytes { bytes -> Bool in
102
+ var source = vImage_Buffer(
103
+ data: UnsafeMutableRawPointer(mutating: bytes.baseAddress!), height: vImagePixelCount(height),
104
+ width: vImagePixelCount(width), rowBytes: width * 4)
105
+ var destination = vImage_Buffer(
106
+ data: CVPixelBufferGetBaseAddress(buffer), height: vImagePixelCount(height), width: vImagePixelCount(width),
107
+ rowBytes: CVPixelBufferGetBytesPerRow(buffer))
108
+ return vImagePermuteChannels_ARGB8888(&source, &destination, [2, 1, 0, 3], vImage_Flags(kvImageNoFlags))
109
+ == kvImageNoError
110
+ }
111
+ CVPixelBufferUnlockBaseAddress(buffer, [])
112
+ if swizzled { encodeOnQueue(buffer, quarterTurns: 0, capturedAt: capturedAt) }
113
+ }
114
+ }
115
+
116
+ private func encodeOnQueue(_ source: CVPixelBuffer, quarterTurns: Int, capturedAt: Double) {
117
+ let sideways = quarterTurns % 2 != 0
118
+ let width = sideways ? CVPixelBufferGetHeight(source) : CVPixelBufferGetWidth(source)
119
+ let height = sideways ? CVPixelBufferGetWidth(source) : CVPixelBufferGetHeight(source)
120
+ let target = Self.fit(width: width, height: height, maxEdge: maxEdge)
121
+ guard let session = session(for: target), let pool = VTCompressionSessionGetPixelBufferPool(session),
122
+ let input = upright(source, quarterTurns: quarterTurns, width: width, height: height)
123
+ else { return }
124
+ var buffer: CVPixelBuffer?
125
+ guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) == kCVReturnSuccess, let buffer,
126
+ let transfer = transferSession(),
127
+ VTPixelTransferSessionTransferImage(transfer, from: input, to: buffer) == noErr
128
+ else { return }
129
+ var timestamp = CMTime(value: CMTimeValue(capturedAt * 1000), timescale: 1_000_000)
130
+ if lastTimestamp.isValid, timestamp <= lastTimestamp {
131
+ timestamp = CMTimeAdd(lastTimestamp, CMTime(value: 1, timescale: 1_000_000))
132
+ }
133
+ lastTimestamp = timestamp
134
+ let properties = forceKeyframe ? [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary : nil
135
+ forceKeyframe = false
136
+ VTCompressionSessionEncodeFrame(
137
+ session, imageBuffer: buffer, presentationTimeStamp: timestamp, duration: .invalid,
138
+ frameProperties: properties, infoFlagsOut: nil
139
+ ) { [output, weak self] status, _, sample in
140
+ guard status == noErr, let sample, let unit = Self.annexB(sample) else {
141
+ self?.requestKeyframe()
142
+ return
143
+ }
144
+ output(
145
+ AccessUnit(
146
+ data: unit.data, keyframe: unit.keyframe, capturedAt: capturedAt, width: target.width, height: target.height))
147
+ }
148
+ }
149
+
150
+ static func fit(width: Int, height: Int, maxEdge: Int) -> (width: Int, height: Int) {
151
+ let scale = min(1, Double(maxEdge) / Double(max(width, height)))
152
+ let even = { (value: Double) in max(2, Int(value.rounded(.down)) & ~1) }
153
+ return (even(Double(width) * scale), even(Double(height) * scale))
154
+ }
155
+
156
+ private func session(for target: (width: Int, height: Int)) -> VTCompressionSession? {
157
+ if let session, size == target { return session }
158
+ invalidate()
159
+ size = target
160
+ forceKeyframe = true
161
+ let specification: [CFString: Any] = [
162
+ kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder: true,
163
+ kVTVideoEncoderSpecification_EnableLowLatencyRateControl: true,
164
+ ]
165
+ let attributes: [CFString: Any] = [
166
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
167
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
168
+ ]
169
+ var created: VTCompressionSession?
170
+ guard
171
+ VTCompressionSessionCreate(
172
+ allocator: nil, width: Int32(target.width), height: Int32(target.height), codecType: kCMVideoCodecType_H264,
173
+ encoderSpecification: specification as CFDictionary, imageBufferAttributes: attributes as CFDictionary,
174
+ compressedDataAllocator: nil, outputCallback: nil, refcon: nil, compressionSessionOut: &created) == noErr,
175
+ let created
176
+ else { return nil }
177
+ let properties: [CFString: Any] = [
178
+ kVTCompressionPropertyKey_RealTime: true,
179
+ kVTCompressionPropertyKey_AllowFrameReordering: false,
180
+ kVTCompressionPropertyKey_ProfileLevel: kVTProfileLevel_H264_Main_AutoLevel,
181
+ kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration: 2,
182
+ kVTCompressionPropertyKey_ExpectedFrameRate: fps,
183
+ ]
184
+ for (key, value) in properties { VTSessionSetProperty(created, key: key, value: value as CFTypeRef) }
185
+ Self.applyBitrate(created, bitrate)
186
+ VTCompressionSessionPrepareToEncodeFrames(created)
187
+ session = created
188
+ return created
189
+ }
190
+
191
+ private func invalidate() {
192
+ guard let session else { return }
193
+ VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
194
+ VTCompressionSessionInvalidate(session)
195
+ self.session = nil
196
+ size = (0, 0)
197
+ forceKeyframe = true
198
+ }
199
+
200
+ private static func applyBitrate(_ session: VTCompressionSession, _ bitrate: Int) {
201
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: bitrate as CFNumber)
202
+ }
203
+
204
+ private func transferSession() -> VTPixelTransferSession? {
205
+ if transfer == nil {
206
+ VTPixelTransferSessionCreate(allocator: nil, pixelTransferSessionOut: &transfer)
207
+ if let transfer {
208
+ VTSessionSetProperty(transfer, key: kVTPixelTransferPropertyKey_ScalingMode, value: kVTScalingMode_Normal)
209
+ }
210
+ }
211
+ return transfer
212
+ }
213
+
214
+ private func upright(_ source: CVPixelBuffer, quarterTurns: Int, width: Int, height: Int) -> CVPixelBuffer? {
215
+ let turns = ((quarterTurns % 4) + 4) % 4
216
+ if turns == 0 { return source }
217
+ if rotation == nil { VTPixelRotationSessionCreate(nil, &rotation) }
218
+ guard let rotation else { return nil }
219
+ let angle = [kVTRotation_0, kVTRotation_CCW90, kVTRotation_180, kVTRotation_CW90][turns]
220
+ VTSessionSetProperty(rotation, key: kVTPixelRotationPropertyKey_Rotation, value: angle)
221
+ if rotated == nil || rotatedSize != (width, height) {
222
+ let attributes: [CFString: Any] = [
223
+ kCVPixelBufferPixelFormatTypeKey: CVPixelBufferGetPixelFormatType(source),
224
+ kCVPixelBufferWidthKey: width,
225
+ kCVPixelBufferHeightKey: height,
226
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
227
+ ]
228
+ rotated = nil
229
+ CVPixelBufferPoolCreate(nil, nil, attributes as CFDictionary, &rotated)
230
+ rotatedSize = (width, height)
231
+ }
232
+ var buffer: CVPixelBuffer?
233
+ guard let rotated, CVPixelBufferPoolCreatePixelBuffer(nil, rotated, &buffer) == kCVReturnSuccess, let buffer,
234
+ VTPixelRotationSessionRotateImage(rotation, source, buffer) == noErr
235
+ else { return nil }
236
+ return buffer
237
+ }
238
+
239
+ private static let startCode: [UInt8] = [0, 0, 0, 1]
240
+
241
+ /// Converts the encoder's length-prefixed (AVCC) sample to Annex B, with the SPS and PPS before a keyframe.
242
+ static func annexB(_ sample: CMSampleBuffer) -> (data: Data, keyframe: Bool)? {
243
+ guard let block = CMSampleBufferGetDataBuffer(sample) else { return nil }
244
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[CFString: Any]]
245
+ let keyframe = !(attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false)
246
+ var out = Data()
247
+ if keyframe, let format = CMSampleBufferGetFormatDescription(sample) {
248
+ var count = 0
249
+ CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
250
+ format, parameterSetIndex: 0, parameterSetPointerOut: nil, parameterSetSizeOut: nil,
251
+ parameterSetCountOut: &count, nalUnitHeaderLengthOut: nil)
252
+ for index in 0..<count {
253
+ var pointer: UnsafePointer<UInt8>?
254
+ var length = 0
255
+ guard
256
+ CMVideoFormatDescriptionGetH264ParameterSetAtIndex(
257
+ format, parameterSetIndex: index, parameterSetPointerOut: &pointer, parameterSetSizeOut: &length,
258
+ parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) == noErr, let pointer
259
+ else { return nil }
260
+ out.append(contentsOf: startCode)
261
+ out.append(pointer, count: length)
262
+ }
263
+ }
264
+ let total = CMBlockBufferGetDataLength(block)
265
+ guard total > 0 else { return nil }
266
+ var bytes = Data(count: total)
267
+ let copied = bytes.withUnsafeMutableBytes {
268
+ CMBlockBufferCopyDataBytes(block, atOffset: 0, dataLength: total, destination: $0.baseAddress!)
269
+ }
270
+ guard copied == noErr else { return nil }
271
+ var offset = 0
272
+ while offset + 4 <= bytes.count {
273
+ let length = bytes[offset..<(offset + 4)].reduce(0) { $0 << 8 | Int($1) }
274
+ offset += 4
275
+ guard length > 0, offset + length <= bytes.count else { return nil }
276
+ out.append(contentsOf: startCode)
277
+ out.append(bytes[offset..<(offset + length)])
278
+ offset += length
279
+ }
280
+ return (out, keyframe)
281
+ }
282
+ }