@pickleball/rtmp-native 0.1.0 → 0.2.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.
@@ -1,7 +1,20 @@
1
1
  import AVFoundation
2
+ import CoreImage
2
3
  import ExpoModulesCore
3
4
  import HaishinKit
5
+ import ImageIO
4
6
  import UIKit
7
+ import VideoToolbox
8
+
9
+ /// Xin quyền camera + mic qua AVFoundation. SDK 1.0 gỡ LiveKit/WebRTC nên không
10
+ /// còn getUserMedia để "mượn" hộp thoại quyền; hỏi ở đây TRƯỚC khi prepare —
11
+ /// hỏi trong lúc AVCaptureSession đang dựng là camera đen im lặng ở lần đầu.
12
+ /// Hỏi tuần tự (video rồi audio) để hai hộp thoại không chồng nhau.
13
+ private func requestCaptureAccess() async -> Bool {
14
+ let video = await AVCaptureDevice.requestAccess(for: .video)
15
+ let audio = await AVCaptureDevice.requestAccess(for: .audio)
16
+ return video && audio
17
+ }
5
18
 
6
19
  /// Cầu nối Expo cho mode deliveryMode="device-rtmp".
7
20
  ///
@@ -13,18 +26,75 @@ import UIKit
13
26
  public final class PickleballRtmpNativeModule: Module {
14
27
  private var mixer: MediaMixer?
15
28
  private var session: (any Session)?
29
+ /// Tổng byte đã RỜI máy (socket RTMP, NetworkMonitor báo mỗi ~1s qua
30
+ /// BandwidthReportingStrategy). -1 = chưa có số liệu. Watchdog publish so
31
+ /// mốc đổi gần nhất để bắt ca "TCP sống mà không frame nào đi" — chính là ca
32
+ /// 26/08/2026: 2 phiên mất 10-13 phút đuôi footage trong khi isConnected
33
+ /// vẫn true và app vẫn tưởng mình phát tốt.
34
+ private var bytesOutSeen: Int = -1
35
+ private var bytesOutChangedAt = Date.distantPast
36
+ /// Mốc event NetworkMonitor gần nhất — actor socket treo cứng là event tắt hẳn.
37
+ private var monitorEventAt = Date.distantPast
38
+ /// Từ lúc nào fps video = 0 trong khi vẫn publish. Đây là chữ ký đo được
39
+ /// 26/08: encoder VIDEO chết (nghi thermal) nhưng AUDIO vẫn chảy — byte vẫn
40
+ /// nhích nên luật byte mù, chỉ fps mới tố được.
41
+ private var fpsZeroSince: Date?
16
42
  // PHẢI khớp mặc định của engine (`isCameraFront: false`) và LiveKitRoomAdapter.
17
43
  // Lệch một chỗ là nhãn UI và hình thực tế ngược nhau vĩnh viễn, và
18
44
  // switchCamera() càng làm lệch thêm.
19
45
  private var cameraPosition: AVCaptureDevice.Position = .back
20
46
  private var microphoneEnabled = true
21
47
  private var publishing = false
48
+ /// Đồng hồ canh socket RTMP khi đang publish. iOS KHÔNG có callback đứt kết
49
+ /// nối như Android (ConnectChecker.onDisconnect) — HaishinKit chỉ lộ
50
+ /// `session.isConnected`, không ai chủ động báo. Thiếu watchdog này thì app
51
+ /// hiện "đang phát" vô hạn sau khi server reset socket (quan sát prod
52
+ /// 17/08/2026: phiên chết 01:15:43, app im lặng, không thử lại).
53
+ private var publishWatchdog: Task<Void, Never>?
22
54
  private var videoBitrate = 6_000_000
23
55
  private var videoSize = CGSize(width: 1920, height: 1080)
56
+ /// Ống kính đang gắn — nguồn cho capabilities và các control zoom/EV/lock.
57
+ private var currentVideoDevice: AVCaptureDevice?
58
+ /// State controls người dùng đã chọn — đổi lens reset state trên
59
+ /// AVCaptureDevice nên phải áp lại sau mỗi attachVideo.
60
+ private var zoomFactor: Double?
61
+ private var exposureBiasValue: Double?
62
+ private var focusLocked = false
63
+ private var exposureLocked = false
64
+ private var torchEnabled = false
65
+ /// Trần zoom thực dụng — vượt quá là ảnh vỡ hạt, không có giá trị cho live thể thao.
66
+ private static let maxZoomCap: CGFloat = 8
24
67
  /// SessionBuilderFactory KHÔNG có sẵn factory nào — phải tự đăng ký, nếu không
25
68
  /// build() ném Error.notFound (error 1). Đăng ký một lần cho cả vòng đời app.
26
69
  private static var sessionFactoriesRegistered = false
27
70
  private var orientationObserver: NSObjectProtocol?
71
+ /// Tap giữ khung hình mới nhất cho snapshot() (dashboard xem trước từ xa).
72
+ /// Thêm vào mixer ở prepare(), gỡ ở teardown(). Không đụng publish/preview.
73
+ private let snapshotOutput = SnapshotOutput()
74
+ /// Logo giải cần nướng vào khung hình (BTC đặt trên dashboard, thiết bị nhận
75
+ /// kèm nhịp beat rồi tải ảnh về đĩa). `nil` = không vẽ gì → mixer giữ nguyên
76
+ /// `.passthrough` như trước, không tốn thêm một vòng render nào.
77
+ private var overlayLogoSpec: OverlayLogoOptions?
78
+ /// Đối tượng vẽ trên `Screen`. Isolate ở `ScreenActor` giống chính `screen`
79
+ /// của HaishinKit — mọi lượt đọc/ghi đều nằm trong `applyOverlayOnScreen`.
80
+ @ScreenActor
81
+ private var overlayLogoObject: ImageScreenObject?
82
+ /// Các lớp overlay ĐỘNG (gif chạy khung / video lặp) — thứ tự mảng là thứ tự
83
+ /// chồng lớp, vẽ TRÊN composite tĩnh. Rỗng = không có lớp động nào.
84
+ private var overlayAnimatedSpecs: [OverlayAnimatedOptions] = []
85
+ /// Object đang gắn trên screen theo id lớp — cùng kỷ luật ScreenActor và cùng
86
+ /// bài học 9d5414e: screen chết theo phiên, object sống xuyên phiên, mỗi lần
87
+ /// áp phải detach khỏi parent THẬT rồi gắn lại.
88
+ @ScreenActor
89
+ private var overlayAnimatedObjects: [String: ScreenObject] = [:]
90
+ /// Vòng đổi khung gif / vòng canh lặp video theo id — huỷ khi lớp bị gỡ/đổi.
91
+ private var overlayAnimatedTasks: [String: Task<Void, Never>] = [:]
92
+ /// Các lớp CHỮ đã điền sẵn (JS điền template tỉ số/tên đội, native chỉ vẽ) —
93
+ /// trên cùng mọi lớp khác. Chuỗi đổi ~2 lần/phút theo pha bóng.
94
+ private var overlayTextSpecs: [OverlayTextOptions] = []
95
+ /// Object chữ theo id — cùng kỷ luật ScreenActor + detach-parent-thật (9d5414e).
96
+ @ScreenActor
97
+ private var overlayTextObjects: [String: ImageScreenObject] = [:]
28
98
 
29
99
  public func definition() -> ModuleDefinition {
30
100
  Name("PickleballRtmpNative")
@@ -33,6 +103,12 @@ public final class PickleballRtmpNativeModule: Module {
33
103
  // View hiển thị camera. Thiếu nó thì màn preview tối đen dù camera đã mở.
34
104
  View(PickleballRtmpPreviewView.self) {}
35
105
 
106
+ // Quyền camera/mic — JS (createRtmpRuntime) gọi trước prepare; binary cũ
107
+ // không có hàm này nên JS guard typeof và để AVFoundation tự hỏi lúc mở.
108
+ AsyncFunction("requestPermissions") { () -> Bool in
109
+ await requestCaptureAccess()
110
+ }
111
+
36
112
  AsyncFunction("prepare") { (options: PrepareOptions) in
37
113
  try await self.prepare(options)
38
114
  }
@@ -79,15 +155,87 @@ public final class PickleballRtmpNativeModule: Module {
79
155
  await self.applyVideoBitrate(bitsPerSecond)
80
156
  }
81
157
 
158
+ // ===== Camera controls (app device-streamer) — toàn API public AVFoundation =====
159
+
160
+ AsyncFunction("getCameraCapabilities") { () -> [String: Any] in
161
+ self.cameraCapabilities()
162
+ }
163
+
164
+ AsyncFunction("setZoom") { (factor: Double) in
165
+ self.zoomFactor = factor
166
+ try self.applyZoom(factor)
167
+ }
168
+
169
+ AsyncFunction("setExposureBias") { (ev: Double) in
170
+ self.exposureBiasValue = ev
171
+ try self.applyExposureBias(ev)
172
+ }
173
+
174
+ AsyncFunction("setFocusLocked") { (locked: Bool) in
175
+ self.focusLocked = locked
176
+ try self.applyFocusLocked(locked)
177
+ }
178
+
179
+ AsyncFunction("setExposureLocked") { (locked: Bool) in
180
+ self.exposureLocked = locked
181
+ try self.applyExposureLocked(locked)
182
+ }
183
+
184
+ AsyncFunction("setTorch") { (enabled: Bool) in
185
+ self.torchEnabled = enabled
186
+ await self.mixer?.setTorchEnabled(enabled)
187
+ }
188
+
82
189
  Function("getStats") {
83
190
  [
84
191
  "isConnected": self.session != nil,
85
192
  "isPublishing": self.publishing,
86
193
  "videoBitrate": self.videoBitrate,
87
194
  "currentFps": 0,
195
+ // Tổng byte đã rời máy — số liệu chẩn đoán cho ca media-stall; -1 khi
196
+ // NetworkMonitor chưa báo lần nào.
197
+ "totalBytesOut": self.bytesOutSeen,
88
198
  ] as [String: Any]
89
199
  }
90
200
 
201
+ /**
202
+ Logo nướng vào khung hình. `nil` = gỡ logo.
203
+
204
+ Hình học tính SẴN Ở JS bằng `logoRect()` của @pickleball/shared — cùng công
205
+ thức dashboard dùng để vẽ preview. Cố ý KHÔNG tính lại ở đây: hai bản công
206
+ thức là hai thứ lệch nhau được, mà cái lệch đó bị nướng vĩnh viễn vào video.
207
+ */
208
+ AsyncFunction("setOverlayLogo") { (options: OverlayLogoOptions?) -> [String: Any] in
209
+ await self.setOverlayLogo(options)
210
+ }
211
+
212
+ AsyncFunction("setOverlayAnimated") { (items: [OverlayAnimatedOptions]?) -> [String: Any] in
213
+ await self.setOverlayAnimated(items)
214
+ }
215
+
216
+ AsyncFunction("setOverlayTexts") { (items: [OverlayTextOptions]?) -> [String: Any] in
217
+ await self.setOverlayTexts(items)
218
+ }
219
+
220
+ AsyncFunction("getDeviceHealth") { () -> [String: Any] in
221
+ Self.deviceHealth()
222
+ }
223
+
224
+ // iOS không có API tương đương ApplicationExitInfo (MetricKit chỉ giao
225
+ // chẩn đoán crash theo lô, trễ tới 24h) — trả rỗng để JS gọi đồng nhất.
226
+ AsyncFunction("getProcessExitReasons") { (max: Int) -> [[String: Any]] in
227
+ _ = max
228
+ return []
229
+ }
230
+
231
+ // Chụp 1 ảnh JPEG base64 từ khung hình đang compose — dashboard xem trước từ xa.
232
+ AsyncFunction("snapshot") { () -> String in
233
+ guard let base64 = self.snapshotOutput.snapshotBase64(maxWidth: 480, quality: 0.5) else {
234
+ throw RtmpException("chưa có khung hình để chụp (camera chưa chạy)")
235
+ }
236
+ return base64
237
+ }
238
+
91
239
  AsyncFunction("dispose") {
92
240
  await self.teardown()
93
241
  }
@@ -124,20 +272,38 @@ public final class PickleballRtmpNativeModule: Module {
124
272
  ? CGSize(width: 1280, height: 720)
125
273
  : CGSize(width: 1920, height: 1080)
126
274
 
127
- try await mixer.attachVideo(
128
- AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: cameraPosition),
129
- track: 0
130
- ) { unit in
275
+ // Ống kính người dùng đã chọn được ưu tiên; id không còn tồn tại trên máy
276
+ // thì lặng lẽ quay về wide mặc định thay vì ném lỗi và chặn cả phiên.
277
+ let requested = options.cameraId.flatMap { id in
278
+ availableCameras().first { $0.uniqueID == id }
279
+ }
280
+ let device = requested ?? AVCaptureDevice.default(
281
+ .builtInWideAngleCamera, for: .video, position: cameraPosition
282
+ )
283
+ if let requested {
284
+ cameraPosition = requested.position == .front ? .front : .back
285
+ }
286
+ try await mixer.attachVideo(device, track: 0) { unit in
131
287
  // Lần gắn đầu cũng phải lật gương như switchCamera(), nếu không khởi động
132
288
  // bằng camera trước sẽ hiện ngược so với mode LiveKit.
133
289
  unit.isVideoMirrored = self.cameraPosition == .front
134
290
  }
291
+ currentVideoDevice = device
135
292
  if microphoneEnabled {
136
293
  try await mixer.attachAudio(AVCaptureDevice.default(for: .audio))
137
294
  }
138
295
  await applyVideoOrientation()
139
296
  observeOrientation()
140
297
  await mixer.startRunning()
298
+ // setFrameRate PHẢI đứng SAU startRunning(). AVCaptureSession áp lại
299
+ // sessionPreset lúc chạy, và preset ghi đè activeFormat — cuốn theo cả
300
+ // activeVideoMin/MaxFrameDuration vừa đặt. Đặt trước thì 24fps bị nuốt và
301
+ // ra 30 (đo thật: 29,85fps), trong khi 60fps lại sống sót vì nó buộc
302
+ // HaishinKit ĐỔI hẳn activeFormat và session chuyển sang inputPriority.
303
+ await mixer.setFrameRate(Float64(options.frameRate))
304
+ await reapplyCameraControls()
305
+ // Tap snapshot nhận khung compose như MTHKView — cho phép chụp ảnh xem trước.
306
+ await mixer.addOutput(snapshotOutput)
141
307
  emitStatus(["status": "idle"])
142
308
  }
143
309
 
@@ -151,6 +317,28 @@ public final class PickleballRtmpNativeModule: Module {
151
317
 
152
318
  await Self.registerSessionFactories()
153
319
 
320
+ // Dọn session cũ TRƯỚC khi dựng cái mới. Thiếu đoạn này thì `self.session =
321
+ // built` bên dưới chỉ thay con trỏ: socket RTMP cũ vẫn mở và stream cũ vẫn
322
+ // nằm trong mixer, không ai tham chiếu tới nữa nhưng vẫn sống.
323
+ //
324
+ // Vì sao connect() bị gọi nhiều lần: engine.ts gọi nó ở start() và LẠI gọi ở
325
+ // reconnect() sau mỗi lỗi. Mỗi vòng reconnect rò một socket.
326
+ //
327
+ // Đã quan sát trên prod 16/08/2026 (phiên k972erjy…): MediaMTX nhận BA kết
328
+ // nối RTMP từ cùng một máy cho một lần publish. Hai cái mồ côi chết vì
329
+ // `i/o timeout` sau 30s (chúng chưa từng publish), và khi path bị dọn theo
330
+ // thì socket ĐANG SỐNG chết cùng — `connection reset by peer` cùng giây đó.
331
+ // Phiên tắt sau 24,1 giây, playlist đóng băng, còn app vẫn hiện "đang phát"
332
+ // vì `publishing` chưa bao giờ được đặt lại.
333
+ if let stale = session {
334
+ stopPublishWatchdog()
335
+ let staleStream = await stale.stream
336
+ await mixer.removeOutput(staleStream)
337
+ try? await stale.close()
338
+ session = nil
339
+ publishing = false
340
+ }
341
+
154
342
  emitStatus(["status": "connecting"])
155
343
  do {
156
344
  // build() trả optional: URL sai scheme (không phải rtmp/srt) thì trả nil
@@ -167,6 +355,35 @@ public final class PickleballRtmpNativeModule: Module {
167
355
  await applyVideoBitrate(options.videoBitrate)
168
356
  await applyAudioBitrate(options.audioBitrate)
169
357
 
358
+ // NetworkMonitor của HaishinKit phát hiện nghẽn (hàng đợi gửi phình ra
359
+ // liên tục) và đưa sự kiện vào bitrate strategy. Strategy của mình KHÔNG
360
+ // tự chỉnh — chỉ báo onBandwidth cho JS để chính sách hạ/hồi nằm một chỗ
361
+ // (RtmpRoomAdapter) cho cả Android lẫn iOS.
362
+ if let rtmpStream = stream as? RTMPStream {
363
+ await rtmpStream.setBitrateStorategy(
364
+ BandwidthReportingStrategy { [weak self] sufficient, totalBytesOut, fps in
365
+ guard let self else { return }
366
+ // Nuôi watchdog media-stall TRƯỚC guard publishing: mốc byte phải
367
+ // sống cả lúc chưa publish để lần publish kế không so với mốc cũ.
368
+ self.monitorEventAt = Date()
369
+ if totalBytesOut != self.bytesOutSeen {
370
+ self.bytesOutSeen = totalBytesOut
371
+ self.bytesOutChangedAt = Date()
372
+ }
373
+ if fps > 0 {
374
+ self.fpsZeroSince = nil
375
+ } else if self.fpsZeroSince == nil {
376
+ self.fpsZeroSince = Date()
377
+ }
378
+ guard self.publishing else { return }
379
+ self.sendEvent("onBandwidth", [
380
+ "sufficient": sufficient,
381
+ "videoBitrate": self.videoBitrate,
382
+ ])
383
+ }
384
+ )
385
+ }
386
+
170
387
  emitStatus(["status": "connected"])
171
388
  if options.publish {
172
389
  try await setPublishing(true)
@@ -184,6 +401,34 @@ public final class PickleballRtmpNativeModule: Module {
184
401
 
185
402
  /// `SessionBuilderFactory.shared` khởi tạo RỖNG. Thiếu bước này thì mọi
186
403
  /// build() đều ném `Error.notFound` dù URL rtmp:// hoàn toàn hợp lệ.
404
+ /**
405
+ Mức tiết giảm nhiệt của máy.
406
+
407
+ 🔴 Vì sao phải là native: không package Expo nào đọc được `thermalState`. Hai
408
+ ngày thực địa 08–09/08/2026 ghi 5.598 mẫu `deviceMetrics` mà `thermal` RỖNG
409
+ TRẮNG, nên lúc nghi máy quá nhiệt thì không có số liệu nào để đối chiếu.
410
+
411
+ 🔴 Quy đổi sang THANG CỦA ANDROID (`PowerManager.THERMAL_STATUS_*`), không
412
+ dùng raw value 0–3 của iOS. Dashboard báo "máy quá nóng" từ mức ≥ 3, mà iOS
413
+ chỉ có 4 bậc trong khi Android có 7 — trả thẳng raw thì `.critical` của iOS
414
+ thành 3 còn `.serious` thành 2, và một máy iPhone sắp tắt vì nóng lại hiện
415
+ nhẹ hơn thực tế. Ánh xạ: nominal→0, fair→1, serious→3, critical→4.
416
+
417
+ `batteryTempC` KHÔNG có trên iOS — không API công khai nào cho nhiệt độ pin.
418
+ Bỏ trường thay vì bịa số; bên nhận đã coi nó optional.
419
+ */
420
+ private static func deviceHealth() -> [String: Any] {
421
+ let thermal: Int
422
+ switch ProcessInfo.processInfo.thermalState {
423
+ case .nominal: thermal = 0
424
+ case .fair: thermal = 1
425
+ case .serious: thermal = 3
426
+ case .critical: thermal = 4
427
+ @unknown default: thermal = 0
428
+ }
429
+ return ["thermal": thermal]
430
+ }
431
+
187
432
  private static func registerSessionFactories() async {
188
433
  guard !sessionFactoriesRegistered else { return }
189
434
  sessionFactoriesRegistered = true
@@ -198,17 +443,86 @@ public final class PickleballRtmpNativeModule: Module {
198
443
  guard !publishing else { return }
199
444
  try await session.connect(.ingest)
200
445
  publishing = true
446
+ startPublishWatchdog(session)
201
447
  emitStatus(["status": "publishing"])
202
448
  } else {
203
449
  guard publishing else { return }
450
+ stopPublishWatchdog()
204
451
  try await session.close()
205
452
  publishing = false
206
453
  emitStatus(["status": "connected"])
207
454
  }
208
455
  }
209
456
 
457
+ /// Canh socket mỗi 2s trong lúc publish. `isConnected` về false (server cắt,
458
+ /// mạng rớt) → dọn session + báo "disconnected" cho JS. Engine phía JS nhận
459
+ /// event này để chạy vòng quay số lại — native KHÔNG tự reconnect, chính sách
460
+ /// retry nằm một chỗ ở engine cho cả hai nền tảng.
461
+ /// Byte đứng yên bao lâu (khi đang publish) thì coi là luồng đã chết dù
462
+ /// isConnected còn true. 1080p30 đẩy ~700KB/s — 30 giây im là chắc chắn bệnh.
463
+ private static let mediaStallTimeout: TimeInterval = 30
464
+
465
+ private func startPublishWatchdog(_ session: any Session) {
466
+ stopPublishWatchdog()
467
+ // Mốc mới cho lượt publish này — không so với tàn dư của lượt trước.
468
+ bytesOutChangedAt = Date()
469
+ monitorEventAt = Date()
470
+ fpsZeroSince = nil
471
+ publishWatchdog = Task { [weak self] in
472
+ while !Task.isCancelled {
473
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
474
+ guard let self, !Task.isCancelled else { return }
475
+ guard self.publishing, self.session === session else { return }
476
+ if await !session.isConnected {
477
+ self.stopPublishWatchdog()
478
+ await self.closeSession(reason: "connection_lost")
479
+ return
480
+ }
481
+ // Ca treo "TCP sống, không frame": isConnected true nhưng tổng byte
482
+ // rời máy đứng yên. CHỈ xét khi đã từng thấy byte thật (>0) — thiếu
483
+ // guard này thì Android/khoảnh khắc trước byte đầu tiên bị cắt oan.
484
+ // Tự đóng như một cú rớt mạng thật để engine JS chạy vòng quay số vô
485
+ // hạn sẵn có — chính sách retry vẫn nằm một chỗ ở engine.
486
+ if self.bytesOutSeen > 0,
487
+ Date().timeIntervalSince(self.bytesOutChangedAt) >= Self.mediaStallTimeout {
488
+ self.stopPublishWatchdog()
489
+ await self.closeSession(reason: "media_stalled")
490
+ return
491
+ }
492
+ // Encoder VIDEO chết nhưng audio còn chảy (chữ ký đo 26/08): byte vẫn
493
+ // nhích nên luật trên mù — fps = 0 kéo dài mới tố được. 25s ≈ 12 nhịp
494
+ // đo liên tiếp không một khung hình nào.
495
+ if let zeroSince = self.fpsZeroSince,
496
+ Date().timeIntervalSince(zeroSince) >= 25 {
497
+ self.stopPublishWatchdog()
498
+ await self.closeSession(reason: "media_stalled")
499
+ return
500
+ }
501
+ // Actor socket treo cứng: event NetworkMonitor tắt hẳn — hai luật trên
502
+ // không còn được nuôi số liệu, phải tự ngắt theo sự im lặng.
503
+ if self.monitorEventAt != Date.distantPast,
504
+ Date().timeIntervalSince(self.monitorEventAt) >= Self.mediaStallTimeout {
505
+ self.stopPublishWatchdog()
506
+ await self.closeSession(reason: "media_stalled")
507
+ return
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ private func stopPublishWatchdog() {
514
+ publishWatchdog?.cancel()
515
+ publishWatchdog = nil
516
+ }
517
+
210
518
  private func closeSession(reason: String) async {
519
+ stopPublishWatchdog()
211
520
  guard let session else { return }
521
+ // Gỡ stream khỏi mixer, không chỉ đóng socket. connect() đã addOutput(stream)
522
+ // nên bỏ qua bước này là để lại một output chết vẫn nhận khung hình — và
523
+ // teardown() cũng không dọn hộ, nó chỉ gỡ snapshotOutput.
524
+ let stream = await session.stream
525
+ await mixer?.removeOutput(stream)
212
526
  try? await session.close()
213
527
  self.session = nil
214
528
  publishing = false
@@ -219,6 +533,7 @@ public final class PickleballRtmpNativeModule: Module {
219
533
  stopObservingOrientation()
220
534
  await closeSession(reason: "dispose")
221
535
  if let mixer {
536
+ await mixer.removeOutput(snapshotOutput)
222
537
  await mixer.stopRunning()
223
538
  try? await mixer.attachAudio(nil)
224
539
  try? await mixer.attachVideo(nil, track: 0)
@@ -237,6 +552,23 @@ public final class PickleballRtmpNativeModule: Module {
237
552
  var settings = await stream.videoSettings
238
553
  settings.bitRate = bitsPerSecond
239
554
  settings.videoSize = videoSize
555
+ // Mặc định của HaishinKit là H264 Baseline 3.1 — level đó chỉ hợp lệ tới
556
+ // 1280x720. Yêu cầu 1920x1080 ở Baseline 3.1 là cấu hình ngoài chuẩn:
557
+ // VideoToolbox có thể không dựng nổi compression session, encoder câm lặng
558
+ // và RTMP chỉ còn track audio (người xem thấy màn đen nhưng vẫn nghe tiếng,
559
+ // còn preview trên máy vẫn bình thường vì nó vẽ từ mixer, trước encoder).
560
+ // AutoLevel để VideoToolbox tự chọn level khớp khung hình.
561
+ settings.profileLevel = kVTProfileLevel_H264_High_AutoLevel as String
562
+ // Tắt B-frame — BẮT BUỘC, và chính dòng profileLevel ngay trên là lý do phải
563
+ // có nó: HaishinKit mặc định `allowFrameReordering = !isBaseline`
564
+ // (VideoCodecSettings.swift), nên khoảnh khắc rời Baseline là encoder bật
565
+ // đảo thứ tự khung hình. RTP/WebRTC không chở được B-frame, MediaMTX từ chối
566
+ // ngay ("WebRTC doesn't support H264 streams with B-frames") nên "Siêu nhanh"
567
+ // đứng hình rồi rơi về HLS chậm 10s — đo tại nguồn RTMP thấy pts < dts
568
+ // (404.304 < 404.339). Android không dính vì MediaCodec không tự bật B-frame.
569
+ // Đổi lại là tốn thêm ~5-10% bitrate cho cùng chất lượng: rẻ, và còn bớt được
570
+ // độ trễ mã hoá vì encoder hết phải giữ bộ đệm đảo khung.
571
+ settings.allowFrameReordering = false
240
572
  // Keyframe 2 giây: khớp ranh giới segment HLS phía server, nếu không segment
241
573
  // sẽ không cắt được đúng chỗ và người xem bị giật.
242
574
  settings.maxKeyFrameIntervalDuration = 2
@@ -316,18 +648,698 @@ public final class PickleballRtmpNativeModule: Module {
316
648
  try await mixer.attachVideo(device, track: 0) { unit in
317
649
  unit.isVideoMirrored = mirrored
318
650
  }
651
+ currentVideoDevice = device
652
+ await reapplyCameraControls()
319
653
  }
320
654
 
321
655
  private func switchCamera() async throws {
322
656
  guard let mixer else { return }
323
657
  let next: AVCaptureDevice.Position = cameraPosition == .back ? .front : .back
324
- try await mixer.attachVideo(
325
- AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: next),
326
- track: 0
327
- ) { unit in
658
+ let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: next)
659
+ try await mixer.attachVideo(device, track: 0) { unit in
328
660
  unit.isVideoMirrored = next == .front
329
661
  }
330
662
  cameraPosition = next
663
+ currentVideoDevice = device
664
+ await reapplyCameraControls()
665
+ }
666
+
667
+ // MARK: - Camera controls
668
+
669
+ /// Bounds cho UI (sheet điều khiển ẩn control có supported:false).
670
+ /// fps mà UI được phép chào. Không hỏi phần cứng ra danh sách tự do: encoder
671
+ /// và chính sách bitrate chỉ được kiểm với ba mức này.
672
+ private static let offeredFrameRates = [24, 30, 60]
673
+
674
+ /// Lọc `offeredFrameRates` theo đúng tiêu chí mà HaishinKit dùng lúc chạy:
675
+ /// `VideoDeviceUnit.setFrameRate` chỉ đổi được sang format CÙNG kích thước với
676
+ /// activeFormat. Không có format nào khớp thì nó im lặng bỏ qua — UI mà chào
677
+ /// 60fps trong trường hợp đó sẽ là nút chết.
678
+ private func supportedFrameRates(for device: AVCaptureDevice) -> [Int] {
679
+ let dims = device.activeFormat.formatDescription.dimensions
680
+ return Self.offeredFrameRates.filter { fps in
681
+ device.formats.contains { format in
682
+ let d = format.formatDescription.dimensions
683
+ guard d.width == dims.width, d.height == dims.height else { return false }
684
+ return format.videoSupportedFrameRateRanges.contains {
685
+ $0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate
686
+ }
687
+ }
688
+ }
689
+ }
690
+
691
+ private func cameraCapabilities() -> [String: Any] {
692
+ guard let device = currentVideoDevice else {
693
+ return [
694
+ "zoom": ["min": 1.0, "max": 1.0, "supported": false],
695
+ "exposureBias": ["min": 0.0, "max": 0.0, "step": 0.1, "supported": false],
696
+ "torch": ["supported": false],
697
+ "focusLock": ["supported": false],
698
+ "exposureLock": ["supported": false],
699
+ // Camera chưa mở: chào mức an toàn nhất thay vì danh sách rỗng, nếu
700
+ // không sheet sẽ không có fps nào để chọn.
701
+ "supportedFrameRates": [30],
702
+ ]
703
+ }
704
+ let minZoom = Double(device.minAvailableVideoZoomFactor)
705
+ let maxZoom = Double(min(device.activeFormat.videoMaxZoomFactor, Self.maxZoomCap))
706
+ return [
707
+ "supportedFrameRates": supportedFrameRates(for: device),
708
+ "zoom": ["min": minZoom, "max": maxZoom, "supported": maxZoom > minZoom],
709
+ "exposureBias": [
710
+ "min": Double(device.minExposureTargetBias),
711
+ "max": Double(device.maxExposureTargetBias),
712
+ // AVFoundation nhận bias liên tục, không có step phần cứng — 0.1 cho slider.
713
+ "step": 0.1,
714
+ "supported": device.maxExposureTargetBias > device.minExposureTargetBias,
715
+ ],
716
+ "torch": ["supported": device.hasTorch],
717
+ "focusLock": ["supported": device.isFocusModeSupported(.locked)],
718
+ "exposureLock": ["supported": device.isExposureModeSupported(.locked)],
719
+ ]
720
+ }
721
+
722
+ private func withDeviceLock(_ body: (AVCaptureDevice) -> Void) throws {
723
+ guard let device = currentVideoDevice else {
724
+ throw RtmpException("chưa có camera — gọi prepare() trước")
725
+ }
726
+ do {
727
+ try device.lockForConfiguration()
728
+ } catch {
729
+ throw RtmpException("không khoá được cấu hình camera: \(error)")
730
+ }
731
+ defer { device.unlockForConfiguration() }
732
+ body(device)
733
+ }
734
+
735
+ private func applyZoom(_ factor: Double) throws {
736
+ try withDeviceLock { device in
737
+ let upper = min(device.activeFormat.videoMaxZoomFactor, Self.maxZoomCap)
738
+ device.videoZoomFactor = max(
739
+ device.minAvailableVideoZoomFactor, min(CGFloat(factor), upper)
740
+ )
741
+ }
742
+ }
743
+
744
+ private func applyExposureBias(_ ev: Double) throws {
745
+ try withDeviceLock { device in
746
+ let clamped = max(
747
+ device.minExposureTargetBias, min(Float(ev), device.maxExposureTargetBias)
748
+ )
749
+ device.setExposureTargetBias(clamped, completionHandler: nil)
750
+ }
751
+ }
752
+
753
+ private func applyFocusLocked(_ locked: Bool) throws {
754
+ try withDeviceLock { device in
755
+ if locked {
756
+ guard device.isFocusModeSupported(.locked) else { return }
757
+ device.focusMode = .locked
758
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
759
+ device.focusMode = .continuousAutoFocus
760
+ } else if device.isFocusModeSupported(.autoFocus) {
761
+ device.focusMode = .autoFocus
762
+ }
763
+ }
764
+ }
765
+
766
+ private func applyExposureLocked(_ locked: Bool) throws {
767
+ try withDeviceLock { device in
768
+ if locked {
769
+ guard device.isExposureModeSupported(.locked) else { return }
770
+ device.exposureMode = .locked
771
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
772
+ device.exposureMode = .continuousAutoExposure
773
+ } else if device.isExposureModeSupported(.autoExpose) {
774
+ device.exposureMode = .autoExpose
775
+ }
776
+ }
777
+ }
778
+
779
+ // MARK: - Overlay
780
+
781
+ /// Trả BÁO CÁO thay vì im lặng — sự cố 26/08: JS áp thành công, native nhận
782
+ /// không ném, hình vẫn trắng, và không ai có một dòng dữ kiện nào. JS log
783
+ /// nguyên object này ra console.
784
+ @discardableResult
785
+ private func setOverlayLogo(_ options: OverlayLogoOptions?) async -> [String: Any] {
786
+ overlayLogoSpec = options
787
+ return await applyOverlayLogo()
788
+ }
789
+
790
+ /**
791
+ Áp trạng thái overlay hiện tại lên mixer.
792
+
793
+ Hai chiều KHÔNG đối xứng, cố ý:
794
+ · bật — dựng xong `screen` RỒI mới chuyển `.offscreen`, nếu không có vài
795
+ khung hình vẽ bằng screen chưa kịp cấu hình (nền đen, chưa có camera);
796
+ · tắt — trả `.passthrough` TRƯỚC rồi mới gỡ child, để không có khung nào
797
+ rơi vào khoảng screen đã mất logo mà mixer vẫn còn đang render offscreen.
798
+
799
+ Ảnh hỏng/không đọc được cũng đi nhánh tắt: thà không có logo còn hơn chặn sóng.
800
+ */
801
+ @discardableResult
802
+ private func applyOverlayLogo() async -> [String: Any] {
803
+ guard let mixer else { return ["applied": false, "why": "no_mixer"] }
804
+ let spec = overlayLogoSpec
805
+ let image = spec.flatMap {
806
+ Self.makeLogoImage(
807
+ path: $0.filePath,
808
+ opacity: $0.opacity,
809
+ targetSize: CGSize(width: $0.width, height: $0.height)
810
+ )
811
+ }
812
+ guard let spec, let image else {
813
+ // Vế tĩnh tắt nhưng còn lớp ĐỘNG thì mixer phải giữ offscreen.
814
+ let fallback: VideoMixerSettings.Mode =
815
+ overlayAnimatedSpecs.isEmpty && overlayTextSpecs.isEmpty ? .passthrough : .offscreen
816
+ await mixer.setVideoMixerSettings(.init(mode: fallback, mainTrack: 0))
817
+ await applyOverlayOnScreen(mixer, image: nil, rect: .zero, frame: videoSize)
818
+ // spec có mà image nil = DECODE HỎNG — trước đây nhánh này lặng lẽ gỡ
819
+ // overlay và không ai biết vì sao hình trắng.
820
+ return [
821
+ "applied": false,
822
+ "why": spec == nil ? "no_spec" : "decode_failed",
823
+ "mode": "passthrough",
824
+ ]
825
+ }
826
+ let rect = CGRect(x: spec.x, y: spec.y, width: spec.width, height: spec.height)
827
+ let attached = await applyOverlayOnScreen(mixer, image: image, rect: rect, frame: videoSize)
828
+ guard attached else {
829
+ let fallback: VideoMixerSettings.Mode =
830
+ overlayAnimatedSpecs.isEmpty && overlayTextSpecs.isEmpty ? .passthrough : .offscreen
831
+ await mixer.setVideoMixerSettings(.init(mode: fallback, mainTrack: 0))
832
+ return ["applied": false, "why": "add_child_failed"]
833
+ }
834
+ await mixer.setVideoMixerSettings(.init(mode: .offscreen, mainTrack: 0))
835
+ // Đọc NGƯỢC từ mixer sau khi đặt — "đã yêu cầu offscreen" và "mixer đang
836
+ // offscreen" là hai mệnh đề khác nhau, và khác biệt đó chính là thứ cần soi.
837
+ let modeNow = await mixer.videoMixerSettings.mode
838
+ return [
839
+ "applied": true,
840
+ "mode": modeNow == .offscreen ? "offscreen" : "khac",
841
+ "imageW": image.width,
842
+ "imageH": image.height,
843
+ "rect": "\(Int(rect.origin.x)),\(Int(rect.origin.y)),\(Int(rect.width))x\(Int(rect.height))",
844
+ "mixerRunning": await mixer.isRunning,
845
+ ]
846
+ }
847
+
848
+ /**
849
+ Kẹp ô vẽ NẰM TRỌN trong khung hình — điều kiện để lớp được vẽ, không phải
850
+ chuyện canh đẹp.
851
+
852
+ `ImageScreenObject.makeImage` (Pods/HaishinKit/…/Screen/ScreenObject.swift)
853
+ so `bounds` với `renderer.bounds`; lệch dù 1 pixel là rơi vào nhánh "quá
854
+ khổ", mà nhánh đó cắt ảnh tại toạ độ TUYỆT ĐỐI trên màn (`x =
855
+ bounds.origin.x`) trong khi `cgImage` chỉ rộng đúng bằng ô. Cắt tại x=1280
856
+ trên ảnh rộng 640 ⇒ vùng cắt nằm ngoài hoàn toàn ⇒ `cropping()` trả nil ⇒
857
+ LỚP KHÔNG VẼ GÌ — im lặng, `applied: true` vẫn xanh.
858
+
859
+ Vế logo/động đã được JS kẹp sẵn; ở đây là lưới đỡ. Vế CHỮ thì đây là chỗ DUY
860
+ NHẤT kẹp được: bề rộng ảnh chữ do `makeTextImage` quyết định lúc chạy nên JS
861
+ không thể biết trước — trước bản vá này chỉ mép trái được kẹp (`max(0,
862
+ left)`), còn mép phải và mép dưới tràn tự do, tức lớp TỈ SỐ có thể biến mất
863
+ không dấu vết.
864
+
865
+ Dời trước, co sau: tràn kiểu làm tròn thì dời vài pixel không ai thấy, còn
866
+ co lại là méo chữ. Chỉ co khi lớp thật sự to hơn cả khung.
867
+ */
868
+ private static func clampedRect(_ rect: CGRect, in frame: CGSize) -> CGRect {
869
+ guard frame.width > 0, frame.height > 0 else { return rect }
870
+ let w = min(max(1, rect.width), frame.width)
871
+ let h = min(max(1, rect.height), frame.height)
872
+ let x = min(max(0, rect.origin.x), frame.width - w)
873
+ let y = min(max(0, rect.origin.y), frame.height - h)
874
+ return CGRect(x: x, y: y, width: w, height: h)
875
+ }
876
+
877
+ /// Mọi thao tác trên `Screen`/`ScreenObject` đều isolate ở `ScreenActor`.
878
+ /// Trả `false` khi không gắn được object vào screen — caller PHẢI báo đỏ.
879
+ @discardableResult
880
+ @ScreenActor
881
+ private func applyOverlayOnScreen(
882
+ _ mixer: MediaMixer,
883
+ image: CGImage?,
884
+ rect: CGRect,
885
+ frame: CGSize
886
+ ) -> Bool {
887
+ let screen = mixer.screen
888
+ guard let image else {
889
+ if let object = overlayLogoObject {
890
+ // Gỡ khỏi parent THẬT của object (có thể là screen của phiên trước) —
891
+ // screen.removeChild trên screen hiện tại là no-op nếu parent khác.
892
+ object.parent?.removeChild(object)
893
+ overlayLogoObject = nil
894
+ }
895
+ return true
896
+ }
897
+ // BẮT BUỘC: Screen.size mặc định 1280×720. Quên dòng này là mọi phiên 1080p
898
+ // bị compose xuống 720 rồi encoder phóng ngược lên — mờ mà không ai báo lỗi.
899
+ screen.size = frame
900
+ let rect = Self.clampedRect(rect, in: frame)
901
+ // KHÔNG chỉnh được `videoGravity` của track camera: HaishinKit để
902
+ // `Screen.videoTrackScreenObject` ở mức internal và không mở API nào duyệt
903
+ // children. May là cấu hình này không cần: module chưa bao giờ gọi
904
+ // `setSessionPreset` nên session chạy `.high` (16:9), còn `videoSize` chỉ
905
+ // nhận 1280x720 hoặc 1920x1080. Nguồn và screen cùng tỉ lệ thì mặc định
906
+ // `.resizeAspect` cho ra đúng khung như `.resizeAspectFill` — không viền
907
+ // đen. Ngày nào ép nguồn sang 4:3 thì phải fork HaishinKit mới sửa được.
908
+ let object = overlayLogoObject ?? ImageScreenObject()
909
+ object.cgImage = image
910
+ // size != .zero nên `makeBounds` lấy thẳng size + layoutMargin làm toạ độ —
911
+ // khớp đúng ô mà `logoRect()` đã tính, không qua một phép canh lề nào nữa.
912
+ object.size = rect.size
913
+ object.horizontalAlignment = .left
914
+ object.verticalAlignment = .top
915
+ object.layoutMargin = .init(top: rect.origin.y, left: rect.origin.x, bottom: 0, right: 0)
916
+ // Mixer + Screen sinh MỚI mỗi phiên, còn object này sống xuyên phiên. Bản cũ
917
+ // chỉ addChild lần đầu đời app → từ phiên thứ hai object vẫn dính screen ĐÃ
918
+ // CHẾT, invalidateLayout vào hư không, report vẫn xanh — sóng thì trắng
919
+ // (bắt quả tang bằng khung hình S1 15:28 26/08). Luôn detach khỏi parent
920
+ // thật rồi gắn lại vào screen hiện tại; addChild từ chối thì trả false.
921
+ object.parent?.removeChild(object)
922
+ do {
923
+ try screen.addChild(object)
924
+ } catch {
925
+ overlayLogoObject = object
926
+ return false
927
+ }
928
+ overlayLogoObject = object
929
+ return true
930
+ }
931
+
932
+ // MARK: - Overlay động (gif/video)
933
+
934
+ @discardableResult
935
+ private func setOverlayAnimated(_ items: [OverlayAnimatedOptions]?) async -> [String: Any] {
936
+ overlayAnimatedSpecs = items ?? []
937
+ return await applyOverlayAnimated()
938
+ }
939
+
940
+ /**
941
+ Áp các lớp ĐỘNG lên mixer. Tách khỏi vế tĩnh vì vòng đời khác hẳn (mỗi lớp
942
+ một object + một Task animator theo id), nhưng chung hai kỷ luật:
943
+ · bất đối xứng bật/tắt của vế tĩnh (passthrough TRƯỚC khi gỡ, dựng xong RỒI
944
+ mới offscreen);
945
+ · detach-khỏi-parent-thật rồi gắn lại (bài học 9d5414e — screen chết theo
946
+ phiên, object sống xuyên phiên).
947
+ Một lớp hỏng chỉ rơi RIÊNG nó (animation là trang trí) — báo cáo mang từng
948
+ item để JS log ra console.
949
+ */
950
+ @discardableResult
951
+ private func applyOverlayAnimated() async -> [String: Any] {
952
+ guard let mixer else { return ["applied": false, "why": "no_mixer"] }
953
+ let specs = overlayAnimatedSpecs
954
+ for (_, task) in overlayAnimatedTasks { task.cancel() }
955
+ overlayAnimatedTasks = [:]
956
+ if specs.isEmpty && overlayLogoSpec == nil && overlayTextSpecs.isEmpty {
957
+ await mixer.setVideoMixerSettings(.init(mode: .passthrough, mainTrack: 0))
958
+ }
959
+ await detachRemovedAnimated(keep: Set(specs.map { $0.id }))
960
+ guard !specs.isEmpty else { return ["applied": true, "count": 0] }
961
+ var details: [[String: Any]] = []
962
+ var ok = 0
963
+ for spec in specs {
964
+ let d = await attachAnimated(mixer, spec: spec)
965
+ if d["applied"] as? Bool == true { ok += 1 }
966
+ details.append(d)
967
+ }
968
+ await mixer.setVideoMixerSettings(.init(mode: .offscreen, mainTrack: 0))
969
+ return ["applied": ok == specs.count, "count": ok, "total": specs.count, "items": details]
970
+ }
971
+
972
+ private func attachAnimated(
973
+ _ mixer: MediaMixer, spec: OverlayAnimatedOptions
974
+ ) async -> [String: Any] {
975
+ let rect = CGRect(x: spec.x, y: spec.y, width: spec.width, height: spec.height)
976
+ guard rect.width >= 1, rect.height >= 1 else {
977
+ return ["id": spec.id, "applied": false, "why": "bad_rect"]
978
+ }
979
+ switch spec.kind {
980
+ case "gif":
981
+ guard
982
+ let gif = GifSource(path: spec.filePath, targetSize: rect.size, opacity: spec.opacity)
983
+ else {
984
+ return ["id": spec.id, "applied": false, "why": "gif_decode_failed"]
985
+ }
986
+ guard
987
+ await attachAnimatedImage(
988
+ mixer, id: spec.id, rect: rect, frame: videoSize, firstFrame: gif.frame(0))
989
+ else {
990
+ return ["id": spec.id, "applied": false, "why": "add_child_failed"]
991
+ }
992
+ startGifTask(id: spec.id, gif: gif)
993
+ return ["id": spec.id, "applied": true, "kind": "gif", "frames": gif.count]
994
+ case "video":
995
+ guard
996
+ await attachAnimatedVideo(
997
+ mixer, id: spec.id, rect: rect, frame: videoSize, filePath: spec.filePath)
998
+ else {
999
+ return ["id": spec.id, "applied": false, "why": "video_attach_failed"]
1000
+ }
1001
+ startVideoLoopTask(id: spec.id)
1002
+ return ["id": spec.id, "applied": true, "kind": "video"]
1003
+ default:
1004
+ return ["id": spec.id, "applied": false, "why": "unknown_kind"]
1005
+ }
1006
+ }
1007
+
1008
+ @ScreenActor
1009
+ private func detachRemovedAnimated(keep: Set<String>) {
1010
+ for (id, object) in overlayAnimatedObjects where !keep.contains(id) {
1011
+ (object as? AssetScreenObject)?.cancelReading()
1012
+ object.parent?.removeChild(object)
1013
+ overlayAnimatedObjects[id] = nil
1014
+ }
1015
+ }
1016
+
1017
+ @ScreenActor
1018
+ private func attachAnimatedImage(
1019
+ _ mixer: MediaMixer, id: String, rect: CGRect, frame: CGSize, firstFrame: CGImage?
1020
+ ) -> Bool {
1021
+ let screen = mixer.screen
1022
+ screen.size = frame
1023
+ let rect = Self.clampedRect(rect, in: frame)
1024
+ let object = (overlayAnimatedObjects[id] as? ImageScreenObject) ?? ImageScreenObject()
1025
+ object.cgImage = firstFrame
1026
+ object.size = rect.size
1027
+ object.horizontalAlignment = .left
1028
+ object.verticalAlignment = .top
1029
+ object.layoutMargin = .init(top: rect.origin.y, left: rect.origin.x, bottom: 0, right: 0)
1030
+ object.parent?.removeChild(object)
1031
+ do { try screen.addChild(object) } catch { return false }
1032
+ overlayAnimatedObjects[id] = object
1033
+ return true
1034
+ }
1035
+
1036
+ @ScreenActor
1037
+ private func attachAnimatedVideo(
1038
+ _ mixer: MediaMixer, id: String, rect: CGRect, frame: CGSize, filePath: String
1039
+ ) -> Bool {
1040
+ let screen = mixer.screen
1041
+ screen.size = frame
1042
+ let rect = Self.clampedRect(rect, in: frame)
1043
+ let object = (overlayAnimatedObjects[id] as? AssetScreenObject) ?? AssetScreenObject()
1044
+ object.videoGravity = .resizeAspect
1045
+ object.size = rect.size
1046
+ object.horizontalAlignment = .left
1047
+ object.verticalAlignment = .top
1048
+ object.layoutMargin = .init(top: rect.origin.y, left: rect.origin.x, bottom: 0, right: 0)
1049
+ object.parent?.removeChild(object)
1050
+ do { try screen.addChild(object) } catch { return false }
1051
+ do {
1052
+ try object.startReading(AVURLAsset(url: URL(fileURLWithPath: filePath)))
1053
+ } catch {
1054
+ object.parent?.removeChild(object)
1055
+ overlayAnimatedObjects[id] = nil
1056
+ return false
1057
+ }
1058
+ overlayAnimatedObjects[id] = object
1059
+ return true
1060
+ }
1061
+
1062
+ /// Đổi khung gif theo delay gốc. Decode-khi-đến-lượt trên executor của Task
1063
+ /// (KHÔNG chặn ScreenActor), rồi chỉ ĐỔI NGUỒN ảnh của đúng một object —
1064
+ /// renderer tự re-layout riêng lớp đó (chi phí ∝ diện tích lớp, đo 26/08).
1065
+ private func startGifTask(id: String, gif: GifSource) {
1066
+ overlayAnimatedTasks[id]?.cancel()
1067
+ guard gif.count > 1 else { return }
1068
+ overlayAnimatedTasks[id] = Task { [weak self] in
1069
+ var index = 0
1070
+ while !Task.isCancelled {
1071
+ let delay = gif.delays[index]
1072
+ try? await Task.sleep(nanoseconds: UInt64(max(0.02, delay) * 1_000_000_000))
1073
+ if Task.isCancelled { return }
1074
+ index = (index + 1) % gif.count
1075
+ guard let image = gif.frame(index) else { continue }
1076
+ await self?.setAnimatedFrame(id: id, image: image)
1077
+ }
1078
+ }
1079
+ }
1080
+
1081
+ @ScreenActor
1082
+ private func setAnimatedFrame(id: String, image: CGImage) {
1083
+ (overlayAnimatedObjects[id] as? ImageScreenObject)?.cgImage = image
1084
+ }
1085
+
1086
+ /// AssetScreenObject không tự lặp — hết file là ngừng đọc. Canh 700ms một
1087
+ /// lần, hết thì mở lại từ đầu (decode cứng, rẻ).
1088
+ private func startVideoLoopTask(id: String) {
1089
+ overlayAnimatedTasks[id]?.cancel()
1090
+ overlayAnimatedTasks[id] = Task { [weak self] in
1091
+ while !Task.isCancelled {
1092
+ try? await Task.sleep(nanoseconds: 700_000_000)
1093
+ if Task.isCancelled { return }
1094
+ await self?.restartVideoIfEnded(id: id)
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ @ScreenActor
1100
+ private func restartVideoIfEnded(id: String) {
1101
+ guard let object = overlayAnimatedObjects[id] as? AssetScreenObject, !object.isReading
1102
+ else { return }
1103
+ guard let spec = overlayAnimatedSpecs.first(where: { $0.id == id }) else { return }
1104
+ try? object.startReading(AVURLAsset(url: URL(fileURLWithPath: spec.filePath)))
1105
+ }
1106
+
1107
+ /**
1108
+ Nguồn gif decode-khi-đến-lượt: giữ CGImageSource (không cache) + mảng delay,
1109
+ mỗi tick mới decode + chuẩn hoá ĐÚNG MỘT khung về cỡ ô (cùng lý do 4ef575c).
1110
+ RAM = O(1 khung) bất kể gif dài bao nhiêu — bài học 26/08: gif thật ngoài
1111
+ sản xuất 268 khung / 27s, pre-decode cần 234MB và bị trần 48MB chặn chết.
1112
+ CPU: ~1 khung decode+vẽ mỗi tick ở nhịp gif (≤25fps) — không đáng kể so với
1113
+ camera track. Trần 2000 khung chỉ để chặn file rác.
1114
+ */
1115
+ private final class GifSource {
1116
+ let delays: [Double]
1117
+ private let source: CGImageSource
1118
+ private let targetSize: CGSize
1119
+ private let opacity: Double
1120
+ private let renderer: UIGraphicsImageRenderer
1121
+
1122
+ var count: Int { delays.count }
1123
+
1124
+ init?(path: String, targetSize: CGSize, opacity: Double) {
1125
+ guard targetSize.width >= 1, targetSize.height >= 1,
1126
+ let src = CGImageSourceCreateWithURL(
1127
+ URL(fileURLWithPath: path) as CFURL,
1128
+ [kCGImageSourceShouldCache: false] as CFDictionary)
1129
+ else { return nil }
1130
+ let count = CGImageSourceGetCount(src)
1131
+ guard count >= 1, count <= 2000 else { return nil }
1132
+ // Khung đầu phải decode được thì mới nhận — file hỏng lộ ngay ở đây
1133
+ // thay vì thành lớp "áp xanh mà không vẽ" giữa chừng.
1134
+ guard CGImageSourceCreateImageAtIndex(src, 0, nil) != nil else { return nil }
1135
+ self.source = src
1136
+ self.targetSize = targetSize
1137
+ self.opacity = opacity
1138
+ let format = UIGraphicsImageRendererFormat.default()
1139
+ format.scale = 1
1140
+ format.opaque = false
1141
+ self.renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
1142
+ self.delays = (0..<count).map {
1143
+ PickleballRtmpNativeModule.gifFrameDelay(source: src, index: $0)
1144
+ }
1145
+ }
1146
+
1147
+ func frame(_ index: Int) -> CGImage? {
1148
+ guard index >= 0, index < delays.count,
1149
+ let cg = CGImageSourceCreateImageAtIndex(
1150
+ source, index, [kCGImageSourceShouldCache: false] as CFDictionary)
1151
+ else { return nil }
1152
+ let size = targetSize
1153
+ let alpha = CGFloat(opacity)
1154
+ return renderer.image { _ in
1155
+ UIImage(cgImage: cg).draw(
1156
+ in: CGRect(origin: .zero, size: size), blendMode: .normal, alpha: alpha)
1157
+ }.cgImage
1158
+ }
1159
+ }
1160
+
1161
+ private static func gifFrameDelay(source: CGImageSource, index: Int) -> Double {
1162
+ guard
1163
+ let props = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
1164
+ let gif = props[kCGImagePropertyGIFDictionary] as? [CFString: Any]
1165
+ else { return 0.1 }
1166
+ let delay =
1167
+ (gif[kCGImagePropertyGIFUnclampedDelayTime] as? Double)
1168
+ ?? (gif[kCGImagePropertyGIFDelayTime] as? Double) ?? 0.1
1169
+ // Gif khai delay 0 nghĩa là "nhanh nhất có thể" — trình duyệt quy về 100ms.
1170
+ return delay < 0.02 ? 0.1 : delay
1171
+ }
1172
+
1173
+ /// Đọc ảnh từ đĩa, nướng độ đậm vào pixel VÀ chuẩn hoá về ĐÚNG kích thước ô
1174
+ /// đích. Hai lý do, cả hai đo được 26/08:
1175
+ /// · A/B trên máy thật: ImageScreenObject với ảnh nguồn 1920×1080 KHÔNG được
1176
+ /// CPU renderer vẽ ra khung (mọi cỡ rect đều trắng), trong khi nguồn ~384px
1177
+ /// vẽ bình thường — nguồn == đích là cấu hình duy nhất đã chứng minh chạy.
1178
+ /// · Blend mỗi khung hình tỉ lệ diện tích ảnh — đưa ảnh về đúng cỡ ô là trả
1179
+ /// đúng giá, không bắt CPU gánh 1920×1080 cho một logo góc màn hình.
1180
+ /// (`ScreenObject` không có thuộc tính opacity nên độ đậm phải vào pixel ở đây.)
1181
+ private static func makeLogoImage(
1182
+ path: String, opacity: Double, targetSize: CGSize
1183
+ ) -> CGImage? {
1184
+ guard let ui = UIImage(contentsOfFile: path) else { return nil }
1185
+ guard targetSize.width >= 1, targetSize.height >= 1 else { return nil }
1186
+ let format = UIGraphicsImageRendererFormat.default()
1187
+ // scale = 1: kích thước tính bằng PIXEL. Để mặc định (scale màn hình) thì ảnh
1188
+ // phình 2-3× so với ô đã tính và logo tràn khỏi góc.
1189
+ format.scale = 1
1190
+ format.opaque = false
1191
+ let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
1192
+ return renderer.image { _ in
1193
+ ui.draw(in: CGRect(origin: .zero, size: targetSize), blendMode: .normal, alpha: CGFloat(opacity))
1194
+ }.cgImage
1195
+ }
1196
+
1197
+ /// Áp lại state controls sau khi attachVideo — best-effort: lens mới không hỗ
1198
+ /// trợ control nào thì bỏ qua control đó, không chặn việc đổi lens.
1199
+ // MARK: - Overlay chữ (tỉ số/tên đội — JS đã điền template, native chỉ vẽ)
1200
+
1201
+ @discardableResult
1202
+ private func setOverlayTexts(_ items: [OverlayTextOptions]?) async -> [String: Any] {
1203
+ overlayTextSpecs = items ?? []
1204
+ return await applyOverlayTexts()
1205
+ }
1206
+
1207
+ /**
1208
+ Áp các lớp CHỮ lên mixer. Mỗi lớp một ImageScreenObject tái dùng theo id;
1209
+ mỗi lượt áp vẽ lại ảnh của lớp (2 lần/phút theo pha bóng — rẻ, đo cùng cỡ
1210
+ với một khung gif). Chung kỷ luật với hai vế kia: passthrough chỉ khi MỌI vế
1211
+ trống, detach-khỏi-parent-thật trước khi gắn (bài học 9d5414e).
1212
+ */
1213
+ @discardableResult
1214
+ private func applyOverlayTexts() async -> [String: Any] {
1215
+ guard let mixer else { return ["applied": false, "why": "no_mixer"] }
1216
+ let specs = overlayTextSpecs
1217
+ if specs.isEmpty && overlayLogoSpec == nil && overlayAnimatedSpecs.isEmpty {
1218
+ await mixer.setVideoMixerSettings(.init(mode: .passthrough, mainTrack: 0))
1219
+ }
1220
+ await detachRemovedTexts(keep: Set(specs.map { $0.id }))
1221
+ guard !specs.isEmpty else { return ["applied": true, "count": 0] }
1222
+ var details: [[String: Any]] = []
1223
+ var ok = 0
1224
+ for spec in specs {
1225
+ guard let image = Self.makeTextImage(spec) else {
1226
+ details.append(["id": spec.id, "applied": false, "why": "render_failed"])
1227
+ continue
1228
+ }
1229
+ // Mỏ neo theo align: x của spec là mép trái/tâm/mép phải của ẢNH đã vẽ
1230
+ // (ảnh ôm trọn nền) — cùng quy ước transform bên preview editor.
1231
+ let w = Double(image.width)
1232
+ let left: Double
1233
+ switch spec.align {
1234
+ case "center": left = spec.x - w / 2
1235
+ case "right": left = spec.x - w
1236
+ default: left = spec.x
1237
+ }
1238
+ let rect = CGRect(
1239
+ x: max(0, left), y: spec.y, width: w, height: Double(image.height))
1240
+ let attached = await attachTextImage(
1241
+ mixer, id: spec.id, rect: rect, frame: videoSize, image: image)
1242
+ if attached { ok += 1 }
1243
+ details.append(["id": spec.id, "applied": attached, "w": image.width, "h": image.height])
1244
+ }
1245
+ await mixer.setVideoMixerSettings(.init(mode: .offscreen, mainTrack: 0))
1246
+ return ["applied": ok == specs.count, "count": ok, "total": specs.count, "items": details]
1247
+ }
1248
+
1249
+ @ScreenActor
1250
+ private func detachRemovedTexts(keep: Set<String>) {
1251
+ for (id, object) in overlayTextObjects where !keep.contains(id) {
1252
+ object.parent?.removeChild(object)
1253
+ overlayTextObjects[id] = nil
1254
+ }
1255
+ }
1256
+
1257
+ @ScreenActor
1258
+ private func attachTextImage(
1259
+ _ mixer: MediaMixer, id: String, rect: CGRect, frame: CGSize, image: CGImage
1260
+ ) -> Bool {
1261
+ let screen = mixer.screen
1262
+ screen.size = frame
1263
+ let rect = Self.clampedRect(rect, in: frame)
1264
+ let object = overlayTextObjects[id] ?? ImageScreenObject()
1265
+ object.cgImage = image
1266
+ object.size = rect.size
1267
+ object.horizontalAlignment = .left
1268
+ object.verticalAlignment = .top
1269
+ object.layoutMargin = .init(top: rect.origin.y, left: rect.origin.x, bottom: 0, right: 0)
1270
+ object.parent?.removeChild(object)
1271
+ do { try screen.addChild(object) } catch {
1272
+ overlayTextObjects[id] = object
1273
+ return false
1274
+ }
1275
+ overlayTextObjects[id] = object
1276
+ return true
1277
+ }
1278
+
1279
+ /**
1280
+ Vẽ MỘT lớp chữ thành ảnh: nền bo góc (nếu có) + chữ, opacity CẢ LỚP nướng
1281
+ vào pixel. Hình học nền là bội của fontSize (0.45 / 0.25 / 0.22) — PHẢI khớp
1282
+ OVERLAY_TEXT_BG của @pickleball/shared: preview editor và khung hình thật
1283
+ chung một công thức, đừng sửa lệch một bên.
1284
+ */
1285
+ private static func makeTextImage(_ spec: OverlayTextOptions) -> CGImage? {
1286
+ let fontSize = CGFloat(max(8, spec.fontSizePx))
1287
+ let font = UIFont.systemFont(
1288
+ ofSize: fontSize, weight: spec.weight == "regular" ? .regular : .bold)
1289
+ let color = Self.colorFromHex(spec.color) ?? .white
1290
+ let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color]
1291
+ let textSize = (spec.text as NSString).size(withAttributes: attributes)
1292
+ let hasBg = spec.bgColor != nil
1293
+ let padX = hasBg ? fontSize * 0.45 : 0
1294
+ let padY = hasBg ? fontSize * 0.25 : 0
1295
+ let width = ceil(textSize.width + padX * 2)
1296
+ let height = ceil(textSize.height + padY * 2)
1297
+ guard width >= 1, height >= 1, width <= 4096, height <= 1024 else { return nil }
1298
+ let format = UIGraphicsImageRendererFormat()
1299
+ format.scale = 1
1300
+ format.opaque = false
1301
+ let renderer = UIGraphicsImageRenderer(
1302
+ size: CGSize(width: width, height: height), format: format)
1303
+ let image = renderer.image { ctx in
1304
+ // setAlpha + transparency layer: opacity áp lên CẢ nhóm (nền + chữ) một
1305
+ // lần — không bị cộng dồn alpha ở chỗ chữ đè lên nền.
1306
+ ctx.cgContext.setAlpha(CGFloat(max(0, min(1, spec.opacity))))
1307
+ ctx.cgContext.beginTransparencyLayer(auxiliaryInfo: nil)
1308
+ if let bgHex = spec.bgColor, let bg = Self.colorFromHex(bgHex) {
1309
+ let path = UIBezierPath(
1310
+ roundedRect: CGRect(x: 0, y: 0, width: width, height: height),
1311
+ cornerRadius: fontSize * 0.22)
1312
+ bg.withAlphaComponent(CGFloat(max(0, min(1, spec.bgOpacity ?? 0.5)))).setFill()
1313
+ path.fill()
1314
+ }
1315
+ (spec.text as NSString).draw(at: CGPoint(x: padX, y: padY), withAttributes: attributes)
1316
+ ctx.cgContext.endTransparencyLayer()
1317
+ }
1318
+ return image.cgImage
1319
+ }
1320
+
1321
+ private static func colorFromHex(_ hex: String) -> UIColor? {
1322
+ var s = hex.trimmingCharacters(in: .whitespaces)
1323
+ if s.hasPrefix("#") { s.removeFirst() }
1324
+ guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
1325
+ return UIColor(
1326
+ red: CGFloat((v >> 16) & 0xff) / 255,
1327
+ green: CGFloat((v >> 8) & 0xff) / 255,
1328
+ blue: CGFloat(v & 0xff) / 255,
1329
+ alpha: 1)
1330
+ }
1331
+
1332
+ private func reapplyCameraControls() async {
1333
+ if let zoomFactor { try? applyZoom(zoomFactor) }
1334
+ if let exposureBiasValue { try? applyExposureBias(exposureBiasValue) }
1335
+ if focusLocked { try? applyFocusLocked(true) }
1336
+ if exposureLocked { try? applyExposureLocked(true) }
1337
+ if torchEnabled { await mixer?.setTorchEnabled(true) }
1338
+ // Đổi ống kính dựng lại đường video của mixer — áp lại overlay ở đây để cả ba
1339
+ // chỗ attachVideo (prepare/selectCamera/switchCamera) dùng chung một lối.
1340
+ await applyOverlayLogo()
1341
+ await applyOverlayAnimated()
1342
+ await applyOverlayTexts()
331
1343
  }
332
1344
 
333
1345
  private func emitStatus(_ payload: [String: Any]) {
@@ -335,11 +1347,152 @@ public final class PickleballRtmpNativeModule: Module {
335
1347
  }
336
1348
  }
337
1349
 
1350
+ // MARK: - Bandwidth
1351
+
1352
+ /// Cầu nối NetworkMonitorEvent của HaishinKit → sự kiện onBandwidth cho JS.
1353
+ ///
1354
+ /// `publishInsufficientBWOccured` = hàng đợi gửi phình ra liên tục (mạng không
1355
+ /// theo kịp encoder); `status` = nhịp đo bình thường (đủ băng thông). Không tự
1356
+ /// chỉnh bitrate ở đây — RtmpRoomAdapter (JS) giữ chính sách chung hai nền tảng.
1357
+ internal final class BandwidthReportingStrategy: HKStreamBitRateStrategy {
1358
+ // Tên trường (mamimum...) giữ nguyên chính tả của HaishinKit 2.0.9. Hai giá
1359
+ // trị này không được dùng vì strategy không tự điều chỉnh.
1360
+ let mamimumVideoBitRate: Int = 0
1361
+ let mamimumAudioBitRate: Int = 0
1362
+ /// (đủ băng thông, tổng byte đã rời máy, fps video nhịp này) — byte + fps
1363
+ /// nuôi watchdog media-stall: byte bắt chết-toàn-phần, fps bắt chết-video-
1364
+ /// còn-audio (chữ ký 26/08).
1365
+ private let report: @Sendable (Bool, Int, Int) -> Void
1366
+
1367
+ init(report: @escaping @Sendable (Bool, Int, Int) -> Void) {
1368
+ self.report = report
1369
+ }
1370
+
1371
+ func adjustBitrate(_ event: NetworkMonitorEvent, stream: some HKStream) async {
1372
+ // currentFPS = số khung hình stream nhận trong nhịp đo vừa rồi (RTMPStream
1373
+ // reset mỗi event) — chính là nhịp tim của VIDEO, độc lập với audio.
1374
+ let fps = await Int((stream as? RTMPStream)?.currentFPS ?? 0)
1375
+ switch event {
1376
+ case .publishInsufficientBWOccured(let r):
1377
+ report(false, r.totalBytesOut, fps)
1378
+ case .status(let r):
1379
+ report(true, r.totalBytesOut, fps)
1380
+ default:
1381
+ break
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ // MARK: - Snapshot
1387
+
1388
+ /// Nhận khung video đã compose từ MediaMixer (đường giống MTHKView) và giữ lại
1389
+ /// pixel buffer mới nhất để snapshot() nén JPEG khi dashboard xin xem trước từ
1390
+ /// xa. Không phát, không hiển thị — chỉ giữ frame gần nhất.
1391
+ private final class SnapshotOutput: MediaMixerOutput, @unchecked Sendable {
1392
+ // UInt8.max để nhận track video như MTHKView (xem View/MTHKView.swift).
1393
+ var videoTrackId: UInt8? = UInt8.max
1394
+ var audioTrackId: UInt8?
1395
+ private let lock = NSLock()
1396
+ private var latest: CVPixelBuffer?
1397
+ private let ciContext = CIContext()
1398
+
1399
+ nonisolated func mixer(_ mixer: MediaMixer, didOutput sampleBuffer: CMSampleBuffer) {
1400
+ guard let pixelBuffer = sampleBuffer.imageBuffer else { return }
1401
+ lock.lock()
1402
+ latest = pixelBuffer
1403
+ lock.unlock()
1404
+ }
1405
+
1406
+ nonisolated func mixer(_ mixer: MediaMixer, didOutput buffer: AVAudioPCMBuffer, when: AVAudioTime) {}
1407
+
1408
+ func selectTrack(_ id: UInt8?, mediaType: CMFormatDescription.MediaType) async {}
1409
+
1410
+ /// Nén khung mới nhất → JPEG base64 (thu nhỏ về maxWidth). nil khi chưa có frame.
1411
+ func snapshotBase64(maxWidth: CGFloat, quality: CGFloat) -> String? {
1412
+ lock.lock()
1413
+ let pixelBuffer = latest
1414
+ lock.unlock()
1415
+ guard let pixelBuffer else { return nil }
1416
+ let image = CIImage(cvImageBuffer: pixelBuffer)
1417
+ let width = image.extent.width
1418
+ let scale = width > maxWidth ? maxWidth / width : 1
1419
+ let scaled = scale < 1
1420
+ ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
1421
+ : image
1422
+ guard let cgImage = ciContext.createCGImage(scaled, from: scaled.extent) else {
1423
+ return nil
1424
+ }
1425
+ guard let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: quality) else {
1426
+ return nil
1427
+ }
1428
+ return data.base64EncodedString()
1429
+ }
1430
+ }
1431
+
338
1432
  // MARK: - Records
339
1433
 
340
1434
  internal struct PrepareOptions: Record {
341
1435
  @Field var quality: Int = 1080
342
1436
  @Field var frameRate: Int = 30
1437
+ /**
1438
+ Ống kính mở ngay ở lần prepare này (uniqueID của AVCaptureDevice).
1439
+
1440
+ PHẢI khai báo dù chỉ để đọc: Expo Record vứt IM LẶNG mọi trường JS gửi xuống
1441
+ mà Swift không khai — thiếu dòng này thì `cameraId` người dùng chọn bốc hơi
1442
+ và máy luôn mở camera sau mặc định sau mỗi lần khởi động app.
1443
+ */
1444
+ @Field var cameraId: String?
1445
+ /**
1446
+ Giữ cho khớp API với Android; iOS KHÔNG dùng vì hướng video lấy từ
1447
+ `UIDevice.current.orientation` trong `applyVideoOrientation()`.
1448
+ */
1449
+ @Field var landscape: Bool = true
1450
+ }
1451
+
1452
+ /**
1453
+ Ô đặt logo, tính bằng PIXEL của khung hình đang encode, gốc trên-trái.
1454
+
1455
+ JS gửi xuống đã qua `logoRect()` (@pickleball/shared) nên ở đây không kiểm biên
1456
+ lại — biên đã được ép ở `clampLogoGeometry` từ lúc BTC lưu cấu hình.
1457
+ */
1458
+ internal struct OverlayLogoOptions: Record {
1459
+ /// Đường dẫn file ảnh trên đĩa máy (app tải về theo URL rồi cache).
1460
+ @Field var filePath: String = ""
1461
+ @Field var x: Double = 0
1462
+ @Field var y: Double = 0
1463
+ @Field var width: Double = 0
1464
+ @Field var height: Double = 0
1465
+ @Field var opacity: Double = 1
1466
+ }
1467
+
1468
+ /// Một lớp overlay ĐỘNG — cùng hệ pixel với OverlayLogoOptions, thêm id để
1469
+ /// native tái dùng object/animator, và kind ("gif" | "video") chọn đường decode.
1470
+ internal struct OverlayAnimatedOptions: Record {
1471
+ @Field var id: String = ""
1472
+ @Field var kind: String = ""
1473
+ @Field var filePath: String = ""
1474
+ @Field var x: Double = 0
1475
+ @Field var y: Double = 0
1476
+ @Field var width: Double = 0
1477
+ @Field var height: Double = 0
1478
+ @Field var opacity: Double = 1
1479
+ }
1480
+
1481
+ /// Một lớp CHỮ đã điền sẵn — JS gửi CHUỖI CHỐT + hình học pixel (mỏ neo x theo
1482
+ /// align); native chỉ vẽ, không biết template/tỉ số. Nền là field PHẲNG
1483
+ /// (bgColor/bgOpacity) — không có bgColor = không nền.
1484
+ internal struct OverlayTextOptions: Record {
1485
+ @Field var id: String = ""
1486
+ @Field var text: String = ""
1487
+ @Field var x: Double = 0
1488
+ @Field var y: Double = 0
1489
+ @Field var fontSizePx: Double = 48
1490
+ @Field var align: String = "left"
1491
+ @Field var weight: String = "bold"
1492
+ @Field var color: String = "#ffffff"
1493
+ @Field var opacity: Double = 1
1494
+ @Field var bgColor: String?
1495
+ @Field var bgOpacity: Double?
343
1496
  }
344
1497
 
345
1498
  internal struct ConnectOptions: Record {