@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.
- package/android/build.gradle +15 -6
- package/android/src/main/AndroidManifest.xml +26 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/CameraSession.kt +252 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/Encoders.kt +222 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/GlCompositor.kt +755 -0
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpNativeModule.kt +975 -135
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpPreviewView.kt +119 -23
- package/android/src/main/java/expo/modules/pickleballrtmp/PickleballRtmpService.kt +126 -0
- package/ios/PickleballRtmpNativeModule.swift +1161 -8
- package/package.json +3 -3
- package/src/PickleballRtmpNative.types.ts +140 -0
- package/src/PickleballRtmpNativeModule.ts +69 -0
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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 {
|