oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import CoreVideo
2import Foundation
3import CallsKit
4import LiveKitWebRTC
5
6/// Two synthetic cameras behind the app's own camera management
7/// (`ManagedCamera`), so the gate drives the same choice, switching and
8/// recovery code the apps run — only the frames are made up.
9///
10/// The cameras differ only in frame size, so that a switch is something
11/// the far side can *see* rather than something this side merely claims.
12/// There is no camera in CI; what the gate can still hold is the plumbing
13/// — that a switch is serialised, that the track and its m-line survive
14/// it, that frames keep arriving across it, and (`stall`) that a camera
15/// which stops sending is noticed and restarted.
16@MainActor final class SyntheticCameras: CameraBackend {
17 private var delegate: RTCVideoCapturerDelegate?
18 private var syntheticCapturer: RTCVideoCapturer?
19 private var task: Task<Void, Never>?
20 private var chosen = SyntheticCameras.first
21 /// Set by the gate: frames stop until the camera is next opened, as an
22 /// ATEM's do when its input changes format.
23 var stalled = false
24 nonisolated static let first = "synthetic-320"
25 nonisolated static let second = "synthetic-480"
26 private var width: Int { chosen == SyntheticCameras.second ? 480 : 320 }
27 private var height: Int { chosen == SyntheticCameras.second ? 360 : 240 }
28 var devicesChanged: (@MainActor () -> Void)?
29 var trouble: (@MainActor () -> Void)?
30
31 func requestAccess() async throws {}
32
33 var devices: [CameraDevice] {
34 [
35 CameraDevice(id: SyntheticCameras.first, name: "Synthetic 320x240"),
36 CameraDevice(id: SyntheticCameras.second, name: "Synthetic 480x360"),
37 ]
38 }
39 func formatCount(_ id: String) -> Int { 1 }
40
41 func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws {
42 guard devices.contains(where: { $0.id == id }) else {
43 throw NSError(
44 domain: "Calls", code: 4,
45 userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."])
46 }
47 chosen = id
48 stalled = false
49 self.delegate = delegate
50 syntheticCapturer = RTCVideoCapturer(delegate: delegate)
51 task?.cancel()
52 task = Task { @MainActor [weak self] in
53 while !Task.isCancelled {
54 self?.frame()
55 try? await Task.sleep(nanoseconds: 66_666_667)
56 }
57 }
58 }
59 func close() async {
60 task?.cancel()
61 task = nil
62 }
63 private func frame() {
64 guard !stalled, let delegate, let syntheticCapturer else { return }
65 var pixel: CVPixelBuffer?
66 CVPixelBufferCreate(
67 kCFAllocatorDefault, width, height, kCVPixelFormatType_32BGRA,
68 [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &pixel)
69 guard let pixel else { return }
70 CVPixelBufferLockBaseAddress(pixel, [])
71 if let base = CVPixelBufferGetBaseAddress(pixel) {
72 memset(
73 base, Int32(Int(Date().timeIntervalSince1970 * 30) % 200 + 30),
74 CVPixelBufferGetDataSize(pixel))
75 }
76 CVPixelBufferUnlockBaseAddress(pixel, [])
77 delegate.capturer(
78 syntheticCapturer,
79 didCapture: RTCVideoFrame(
80 buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0,
81 timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000)))
82 }
83}
84
85/// A screen with nothing on it but frames, for the gates: there is no
86/// display to share in CI and no person to answer a picker. What it can
87/// still hold is everything after the picker — the source, the sendonly
88/// transceiver, the `share` message, and both of §5.4's endings, the
89/// button's and the capture's own (`end`, which is the system's stop
90/// control as far as the call can tell).
91@MainActor final class SyntheticScreen: ScreenCapture {
92 private var source: RTCVideoSource?
93 private var capturer: RTCVideoCapturer?
94 private var task: Task<Void, Never>?
95 private var ended: (@MainActor () -> Void)?
96 /// A 1920×1080 page of text scrolling at 30 frames a second, for the
97 /// motion gate: what someone scrolling a document looks like to the
98 /// encoder, which is where a screencast source falls behind.
99 private let page: ScrollingPage?
100
101 init(motion: Bool = false) { page = motion ? ScrollingPage() : nil }
102
103 func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
104 self.source = source
105 self.ended = ended
106 let capturer = RTCVideoCapturer(delegate: source)
107 self.capturer = capturer
108 if let page {
109 // Off the main actor and on a timer of its own, as a real capture
110 // is: the gate measures WebRTC, not this process's main thread.
111 page.start(source: source, capturer: capturer)
112 return
113 }
114 task = Task { @MainActor [weak self] in
115 while !Task.isCancelled {
116 self?.frame()
117 try? await Task.sleep(nanoseconds: 100_000_000)
118 }
119 }
120 }
121 /// Frames the moving page has produced, for the gate.
122 var produced: Int { page?.produced ?? 0 }
123 /// A capture that has stalled: no frames, and nothing else either, until
124 /// the watchdog restarts it (`screen-stall`).
125 var stalled = false
126 private var made: UInt64 = 0
127 private(set) var restarts = 0
128 var counts: ScreenCaptureCounts {
129 let n = page.map { UInt64($0.produced) } ?? made
130 return ScreenCaptureCounts(heartbeats: n, delivered: n)
131 }
132 var frameSize: (width: Int, height: Int)? { page != nil ? (ScrollingPage.width, ScrollingPage.height) : (640, 360) }
133 func restart() async -> Bool {
134 guard source != nil else { return false }
135 restarts += 1
136 stalled = false
137 page?.stalled = false
138 return true
139 }
140 func stall() {
141 stalled = true
142 page?.stalled = true
143 }
144 func stop() {
145 page?.stop()
146 task?.cancel()
147 task = nil
148 source = nil
149 capturer = nil
150 ended = nil
151 }
152 /// The capture ending on its own, as a shared window closing would.
153 func end() {
154 let ended = self.ended
155 stop()
156 ended?()
157 }
158 private func frame() {
159 guard let source, let capturer, !stalled else { return }
160 made += 1
161 // A 16:9 screen, wider than either synthetic camera, so the far side
162 // can tell by size alone which one it is decoding.
163 var pixel: CVPixelBuffer?
164 CVPixelBufferCreate(
165 kCFAllocatorDefault, 640, 360, kCVPixelFormatType_32BGRA,
166 [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary, &pixel)
167 guard let pixel else { return }
168 CVPixelBufferLockBaseAddress(pixel, [])
169 if let base = CVPixelBufferGetBaseAddress(pixel) {
170 memset(
171 base, Int32(Int(Date().timeIntervalSince1970 * 10) % 100 + 120),
172 CVPixelBufferGetDataSize(pixel))
173 }
174 CVPixelBufferUnlockBaseAddress(pixel, [])
175 source.capturer(
176 capturer,
177 didCapture: RTCVideoFrame(
178 buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0,
179 timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000)))
180 }
181}
182
183/// Lines of dark "glyphs" on white, twice a screen tall, shown through a
184/// 1920×1080 window that moves down 12 rows a frame and wraps: a fast
185/// scroll through a dense document, at 30 frames a second, with a window
186/// dragged across it. Built once;
187/// each frame is row copies into a pooled buffer, on a queue of its own.
188final class ScrollingPage: @unchecked Sendable {
189 static let width = 1920
190 /// 1080 unless `CALLS_SYNTHETIC_SCREEN_HEIGHT` says otherwise: 1246 is a
191 /// 16:10 Retina display as the Mac's capture fits it to 1920 wide, the
192 /// size that meets H.264 level 3.1's block limit (spec §15.21, §15.22).
193 static let height = Int(ProcessInfo.processInfo.environment["CALLS_SYNTHETIC_SCREEN_HEIGHT"] ?? "") ?? 1080
194 private let rows = ScrollingPage.height * 2
195 private let texture: [UInt8]
196 private var offset = 0
197 private var frame = 0
198 private let queue = DispatchQueue(label: "calls.synthetic-screen", qos: .userInteractive)
199 private var timer: DispatchSourceTimer?
200 private var pool: CVPixelBufferPool?
201 private let lock = NSLock()
202 private var count = 0
203 var produced: Int { lock.withLock { count } }
204 private var stalledFlag = false
205 /// Set by the gate's `screen-stall`: no frames until cleared.
206 var stalled: Bool {
207 get { lock.withLock { stalledFlag } }
208 set { lock.withLock { stalledFlag = newValue } }
209 }
210
211 init() {
212 let stride = Self.width * 4
213 var texture = [UInt8](repeating: 0xF4, count: stride * rows)
214 var seed: UInt64 = 0x9E37_79B9_7F4A_7C15
215 func random(_ n: Int) -> Int {
216 seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
217 return Int((seed >> 33) % UInt64(n))
218 }
219 let rows = self.rows
220 texture.withUnsafeMutableBytes { raw in
221 guard let base = raw.baseAddress else { return }
222 var line = 8
223 while line + 16 < rows {
224 var x = 40 + random(40)
225 let end = Self.width - 40 - random(600)
226 while x < end {
227 let glyph = 5 + random(9)
228 let tall = 9 + random(6)
229 let shade = Int32(20 + random(60))
230 for y in (line + 14 - tall)..<(line + 14) {
231 memset(base + y * stride + x * 4, shade, min(glyph, end - x) * 4)
232 }
233 x += glyph + 2 + (random(6) == 0 ? 8 : 0)
234 }
235 line += 22
236 }
237 }
238 self.texture = texture
239 CVPixelBufferPoolCreate(
240 kCFAllocatorDefault, nil,
241 [
242 kCVPixelBufferWidthKey: Self.width, kCVPixelBufferHeightKey: Self.height,
243 kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
244 kCVPixelBufferIOSurfacePropertiesKey: [:],
245 ] as CFDictionary, &pool)
246 }
247
248 func start(source: RTCVideoSource, capturer: RTCVideoCapturer) {
249 // Strict: a lenient timer on a background process is coalesced to a
250 // third of this rate, which measured the timer and not WebRTC.
251 let timer = DispatchSource.makeTimerSource(flags: .strict, queue: queue)
252 timer.schedule(deadline: .now(), repeating: .nanoseconds(33_333_333), leeway: .milliseconds(2))
253 timer.setEventHandler { [weak self] in
254 guard let self, !self.stalled, let pixel = self.next() else { return }
255 source.capturer(
256 capturer,
257 didCapture: RTCVideoFrame(
258 buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0,
259 timeStampNs: Int64(DispatchTime.now().uptimeNanoseconds)))
260 self.lock.withLock { self.count += 1 }
261 }
262 self.timer = timer
263 timer.resume()
264 }
265
266 func stop() {
267 timer?.cancel()
268 timer = nil
269 }
270
271 private func bounce(_ travelled: Int, _ span: Int) -> Int {
272 let lap = travelled % (2 * span)
273 return lap < span ? lap : 2 * span - lap
274 }
275
276 private func next() -> CVPixelBuffer? {
277 guard let pool else { return nil }
278 var pixel: CVPixelBuffer?
279 CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixel)
280 guard let pixel else { return nil }
281 CVPixelBufferLockBaseAddress(pixel, [])
282 defer { CVPixelBufferUnlockBaseAddress(pixel, []) }
283 guard let base = CVPixelBufferGetBaseAddress(pixel) else { return nil }
284 let destination = CVPixelBufferGetBytesPerRow(pixel)
285 let stride = Self.width * 4
286 let first = offset
287 let rows = self.rows
288 frame += 1
289 // A window being dragged across the page — 800×450, moving 10 rows
290 // and 14 columns a frame and bouncing off the edges — whose content
291 // changes every half second, as a page loading or a slide advancing
292 // would. Together with the scroll beneath it, what someone moving
293 // things around a busy screen sends the encoder.
294 let span = (x: Self.width - 800, y: Self.height - 450)
295 let wx = bounce(frame * 14, span.x)
296 let wy = bounce(frame * 10, span.y)
297 let scene = frame / 15
298 let jump = (scene &* 7919) % (rows - 450)
299 let shift = ((scene &* 104_729) % 900) * 4
300 texture.withUnsafeBytes { raw in
301 guard let source = raw.baseAddress else { return }
302 for y in 0..<Self.height {
303 memcpy(base + y * destination, source + ((first + y) % rows) * stride, stride)
304 if y >= wy, y < wy + 450 {
305 memcpy(base + y * destination + wx * 4, source + (jump + y - wy) * stride + shift, 800 * 4)
306 }
307 }
308 }
309 offset = (offset + 12) % rows
310 return pixel
311 }
312}
313