@mentra/acs-meeting 3.3.0-dev.488 → 3.3.0-dev.489
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/README.md +30 -2
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +91 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +86 -11
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +12 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/OutgoingVideoHold.kt +198 -0
- package/ios/AcsFrameSender.swift +12 -0
- package/ios/AcsMeetingModule.swift +212 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,8 +34,10 @@ differences are collected in [iOS](#ios).
|
|
|
34
34
|
## Mental model
|
|
35
35
|
|
|
36
36
|
**The phone is a relay, not a capture device.** It receives what the glasses already
|
|
37
|
-
encoded, decodes it, and re-publishes it into ACS.
|
|
38
|
-
|
|
37
|
+
encoded, decodes it, and re-publishes it into ACS. Live video is not drawn on the phone,
|
|
38
|
+
and a live frame does not cross the JavaScript bridge. A photo shutter is the exception:
|
|
39
|
+
the phone draws a "Taking a photo" card, then a still, and feeds those pixels through the
|
|
40
|
+
same sender. See [Photo hold](#photo-hold).
|
|
39
41
|
|
|
40
42
|
```
|
|
41
43
|
glasses ──H.264 over WHIP──▶ [SoftAP listener | Cloudflare] ──▶ phone decode ──▶ ACS ──▶ Teams
|
|
@@ -533,6 +535,32 @@ The actual submit runs on the `acs-i420-send` thread with a 200 ms timeout. Two
|
|
|
533
535
|
prefers the stream clock when ACS is advancing it, falls back to capture time, and always
|
|
534
536
|
emits a tick strictly after the last one.
|
|
535
537
|
|
|
538
|
+
### Photo hold
|
|
539
|
+
|
|
540
|
+
A high-resolution photo cannot ride the live camera. The host pauses only the glasses
|
|
541
|
+
publisher (`start_stream` on Direct link; Call's own stream on Cloudflare), keeps the
|
|
542
|
+
meeting, the hotspot, and the ACS sender up, and replaces the tile with three pictures
|
|
543
|
+
in order:
|
|
544
|
+
|
|
545
|
+
1. A full-frame card, "Taking a photo", from the button press until the still is ready.
|
|
546
|
+
2. The still, for a few seconds counted by the caller from `shownAt`.
|
|
547
|
+
3. Live video again.
|
|
548
|
+
|
|
549
|
+
The card and the still are generated on the phone (`OutgoingVideoHold` on Android,
|
|
550
|
+
`outgoingHoldPixelBuffer` on iOS) and pumped about every 100 ms through the existing
|
|
551
|
+
frame sender. Each frame carries a fresh timestamp. A gap, or a timestamp of zero, makes
|
|
552
|
+
Teams hold a random freeze. The last live frame is not repeated.
|
|
553
|
+
|
|
554
|
+
While the hold is active, glasses frames are dropped (`forwardGlassesPlanes` /
|
|
555
|
+
`holdingOutgoing`). `holdOutgoingVideo` resolves only after one replacement frame has
|
|
556
|
+
been handed to the sender. Leave, End, and a new join stop the hold and cancel the
|
|
557
|
+
pause without starting the publisher again. A pause the caller never resumes expires
|
|
558
|
+
after 30 seconds, the publisher starts, and the host emits `miniapp_meeting_video_publisher`
|
|
559
|
+
with `status: "expired"`. A resume that names a stale pause id does nothing.
|
|
560
|
+
|
|
561
|
+
Direct link must not use the ordinary camera-loss recovery for this pause. That path
|
|
562
|
+
rebuilds the hotspot. `PhotoPauseController` suppresses it until the pause ends.
|
|
563
|
+
|
|
536
564
|
## Audio uplink path
|
|
537
565
|
|
|
538
566
|
### Choosing the microphone
|
|
@@ -7,6 +7,7 @@ import com.mentra.glassesmedia.network.InternetHold
|
|
|
7
7
|
import com.mentra.glassesmedia.network.ScopedNetworkChangeDetector
|
|
8
8
|
import com.mentra.glassesmedia.network.ScopedNetworkError
|
|
9
9
|
import com.mentra.glassesmedia.network.ScopedSoftApNetwork
|
|
10
|
+
import com.mentra.glassesmedia.source.StillPhotoInbox
|
|
10
11
|
import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
|
|
11
12
|
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
12
13
|
import com.mentra.acsmeeting.video.VideoProfile
|
|
@@ -17,6 +18,11 @@ import expo.modules.kotlin.Promise
|
|
|
17
18
|
class AcsMeetingModule : Module() {
|
|
18
19
|
private var session: AcsMeetingSession? = null
|
|
19
20
|
|
|
21
|
+
/** Still timeouts and the decode into the outgoing hold, off the ingest connection thread. */
|
|
22
|
+
private val stillTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { runnable ->
|
|
23
|
+
Thread(runnable, "acs-glasses-still").apply { isDaemon = true }
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* Wrap one `AsyncFunction` body in an entry/exit trace.
|
|
22
28
|
*
|
|
@@ -429,6 +435,91 @@ class AcsMeetingModule : Module() {
|
|
|
429
435
|
session?.setMuted(muted) ?: mapOf("state" to "idle", "muted" to muted)
|
|
430
436
|
}
|
|
431
437
|
|
|
438
|
+
AsyncFunction("holdOutgoingVideo") { kind: String, imageBase64: String?, promise: Promise ->
|
|
439
|
+
val meeting = session
|
|
440
|
+
if (meeting == null) {
|
|
441
|
+
promise.reject("NO_MEETING", "No active meeting", null)
|
|
442
|
+
} else {
|
|
443
|
+
val bytes = imageBase64?.let { android.util.Base64.decode(it, android.util.Base64.DEFAULT) }
|
|
444
|
+
meeting.setOutgoingHold(kind, bytes) { handed ->
|
|
445
|
+
if (handed) promise.resolve(null) else promise.reject("HOLD_FAILED", "The outgoing card was not sent", null)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Register a full-size still the glasses will upload during a Direct-link call, and return the
|
|
452
|
+
* URL they must POST it to. Must resolve before the glasses are asked to shoot, so the upload
|
|
453
|
+
* can never arrive for an id the listener does not know.
|
|
454
|
+
*/
|
|
455
|
+
AsyncFunction("prepareGlassesStill") { requestId: String ->
|
|
456
|
+
traced("prepare_glasses_still", "requestId" to requestId) {
|
|
457
|
+
val meeting = session ?: throw IllegalStateException("No active meeting")
|
|
458
|
+
val uploadUrl = meeting.softApStillUrl(requestId)
|
|
459
|
+
?: throw IllegalStateException("This call has no Direct link receiver")
|
|
460
|
+
StillPhotoInbox.expect(requestId)
|
|
461
|
+
mapOf("uploadUrl" to uploadUrl)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Wait for the prepared still, then hold it on the outgoing tile. The JPEG stays native: it is
|
|
467
|
+
* decoded straight into the hold rather than crossing the bridge. Resolves once the first still
|
|
468
|
+
* frame is handed to the Teams sender.
|
|
469
|
+
*/
|
|
470
|
+
AsyncFunction("awaitGlassesStill") { requestId: String, timeoutMs: Int, promise: Promise ->
|
|
471
|
+
val pending = StillPhotoInbox.lookup(requestId)
|
|
472
|
+
if (pending == null) {
|
|
473
|
+
promise.reject("STILL_NOT_PREPARED", "No still is pending for $requestId", null)
|
|
474
|
+
return@AsyncFunction
|
|
475
|
+
}
|
|
476
|
+
val timeout = stillTimer.schedule({
|
|
477
|
+
pending.completeExceptionally(java.util.concurrent.TimeoutException("still_timeout"))
|
|
478
|
+
}, timeoutMs.toLong().coerceAtLeast(1L), java.util.concurrent.TimeUnit.MILLISECONDS)
|
|
479
|
+
pending.whenCompleteAsync({ bytes, error ->
|
|
480
|
+
timeout.cancel(false)
|
|
481
|
+
StillPhotoInbox.cancel(requestId)
|
|
482
|
+
val cause = (error as? java.util.concurrent.CompletionException)?.cause ?: error
|
|
483
|
+
when {
|
|
484
|
+
cause is java.util.concurrent.CancellationException ->
|
|
485
|
+
promise.reject("STILL_CANCELLED", "The still was cancelled", null)
|
|
486
|
+
cause is java.util.concurrent.TimeoutException ->
|
|
487
|
+
promise.reject("STILL_TIMEOUT", "The glasses did not upload the still in ${timeoutMs}ms", null)
|
|
488
|
+
cause != null -> promise.reject("STILL_FAILED", cause.message ?: "Still upload failed", cause)
|
|
489
|
+
else -> {
|
|
490
|
+
val meeting = session
|
|
491
|
+
if (meeting == null) {
|
|
492
|
+
promise.reject("NO_MEETING", "The meeting ended before the still arrived", null)
|
|
493
|
+
} else {
|
|
494
|
+
val holdStartedAt = android.os.SystemClock.elapsedRealtime()
|
|
495
|
+
SoftApTrace.stage("glasses_still_hold", "requestId" to requestId, "bytes" to bytes.size)
|
|
496
|
+
meeting.setOutgoingHold("image", bytes) { handed ->
|
|
497
|
+
val holdMs = android.os.SystemClock.elapsedRealtime() - holdStartedAt
|
|
498
|
+
SoftApTrace.stage(
|
|
499
|
+
"glasses_still_handed",
|
|
500
|
+
"requestId" to requestId,
|
|
501
|
+
"bytes" to bytes.size,
|
|
502
|
+
"handed" to handed,
|
|
503
|
+
"holdMs" to holdMs,
|
|
504
|
+
)
|
|
505
|
+
if (handed) {
|
|
506
|
+
meeting.noteStillHeld(bytes.size)
|
|
507
|
+
promise.resolve(mapOf("bytes" to bytes.size, "shownAt" to System.currentTimeMillis()))
|
|
508
|
+
} else {
|
|
509
|
+
promise.reject("HOLD_FAILED", "The still was not sent to the meeting", null)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}, stillTimer)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Drop one pending still, or every pending still when [requestId] is null. Idempotent. */
|
|
519
|
+
AsyncFunction("cancelGlassesStill") { requestId: String? ->
|
|
520
|
+
if (requestId == null) StillPhotoInbox.cancelAll() else StillPhotoInbox.cancel(requestId)
|
|
521
|
+
}
|
|
522
|
+
|
|
432
523
|
AsyncFunction("setVideoEnabled") { enabled: Boolean, promise: Promise ->
|
|
433
524
|
val meeting = session
|
|
434
525
|
if (meeting == null) {
|
|
@@ -79,6 +79,8 @@ import com.mentra.glassesmedia.source.GlassesMediaSourceFactory
|
|
|
79
79
|
import com.mentra.glassesmedia.source.LocalWhipIngestSource
|
|
80
80
|
import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
|
|
81
81
|
import com.mentra.glassesmedia.source.SourceConfig
|
|
82
|
+
import com.mentra.glassesmedia.source.StillPhotoInbox
|
|
83
|
+
import com.mentra.glassesmedia.source.WhipIngestProtocol
|
|
82
84
|
import com.mentra.glassesmedia.source.SourceKind
|
|
83
85
|
import com.mentra.glassesmedia.source.SourceState
|
|
84
86
|
import com.mentra.glassesmedia.source.SyntheticI420Source
|
|
@@ -89,6 +91,8 @@ import com.mentra.glassesmedia.telemetry.PipelineStats
|
|
|
89
91
|
import com.mentra.glassesmedia.telemetry.PipelineTicker
|
|
90
92
|
import com.mentra.glassesmedia.trace.SoftApTrace
|
|
91
93
|
import com.mentra.acsmeeting.video.AcsFrameSender
|
|
94
|
+
import com.mentra.acsmeeting.video.OutgoingVideoHold
|
|
95
|
+
import com.mentra.glassesmedia.source.I420Planes
|
|
92
96
|
import com.mentra.acsmeeting.video.VideoProfile
|
|
93
97
|
import java.util.concurrent.CountDownLatch
|
|
94
98
|
import java.util.concurrent.Executors
|
|
@@ -134,6 +138,13 @@ class AcsMeetingSession(
|
|
|
134
138
|
private val outgoingReady = AtomicBoolean(false)
|
|
135
139
|
private val muted = AtomicBoolean(false)
|
|
136
140
|
private val frameSender = AcsFrameSender(stats, avSync)
|
|
141
|
+
private val outgoingHold = OutgoingVideoHold(
|
|
142
|
+
send = { planes -> frameSender.sendPlanes(planes) },
|
|
143
|
+
// The negotiated size, not the profile ceiling: ACS renegotiates below the ceiling under load,
|
|
144
|
+
// and sendPlanes drops any hold frame whose dimensions differ from the negotiated format.
|
|
145
|
+
width = { frameSender.negotiatedSize()?.width ?: profile.width },
|
|
146
|
+
height = { frameSender.negotiatedSize()?.height ?: profile.height },
|
|
147
|
+
)
|
|
137
148
|
private var profile = VideoProfile.DEFAULT
|
|
138
149
|
private val resolvedFactory = mediaSourceFactory ?: GlassesMediaSourceFactory { video, pcm, config ->
|
|
139
150
|
// The synthetic diagnostic arm overrides everything; otherwise the requested kind decides.
|
|
@@ -222,6 +233,9 @@ class AcsMeetingSession(
|
|
|
222
233
|
// Health of the glasses WHEP feed, reported alongside the ACS phase so the host
|
|
223
234
|
// can tell "call is up, glasses video is dead" from a healthy call.
|
|
224
235
|
@Volatile private var mediaSource = SourceState.IDLE
|
|
236
|
+
/** Elapsed realtime when the last full-size still was handed to the Teams sender. 0 if none. */
|
|
237
|
+
@Volatile private var lastStillHeldAtMs = 0L
|
|
238
|
+
@Volatile private var lastStillBytes = 0
|
|
225
239
|
// The transport of the active call. A SoftAP source must not be auto-rebuilt: its URL is an
|
|
226
240
|
// output of binding, so a rebuild rebinds a new port and strands the glasses on the old one.
|
|
227
241
|
@Volatile private var currentSourceKind = SourceKind.WHEP
|
|
@@ -644,12 +658,7 @@ class AcsMeetingSession(
|
|
|
644
658
|
// The pin is back on by the time the Teams join below runs.
|
|
645
659
|
bindIngestUnpinned {
|
|
646
660
|
media.attach(
|
|
647
|
-
video = { planes ->
|
|
648
|
-
// Preview first, deliberately: the tap must see frames ACS's pacing and
|
|
649
|
-
// readiness gates would otherwise hide, and it cannot delay or break this call.
|
|
650
|
-
DecodedFrameTap.offer(planes)
|
|
651
|
-
if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
|
|
652
|
-
},
|
|
661
|
+
video = { planes -> forwardGlassesPlanes(planes) },
|
|
653
662
|
pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
|
|
654
663
|
config = videoSource.toConfig(),
|
|
655
664
|
)
|
|
@@ -674,10 +683,7 @@ class AcsMeetingSession(
|
|
|
674
683
|
|
|
675
684
|
if (videoSource !is MeetingVideoSourceSpec.SoftAp) {
|
|
676
685
|
media.attach(
|
|
677
|
-
video = { planes ->
|
|
678
|
-
DecodedFrameTap.offer(planes)
|
|
679
|
-
if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
|
|
680
|
-
},
|
|
686
|
+
video = { planes -> forwardGlassesPlanes(planes) },
|
|
681
687
|
pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
|
|
682
688
|
config = if (synthetic) SourceConfig("", SourceKind.DIRECT) else videoSource.toConfig(),
|
|
683
689
|
)
|
|
@@ -773,6 +779,17 @@ class AcsMeetingSession(
|
|
|
773
779
|
*/
|
|
774
780
|
fun softApIngestUrl(): String? = media.ingestUrl
|
|
775
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Where the glasses upload a full-size still for [requestId] during this SoftAP call: the same
|
|
784
|
+
* listener, host and port as the WHIP endpoint. Null for other transports or before the bind.
|
|
785
|
+
*/
|
|
786
|
+
fun softApStillUrl(requestId: String): String? {
|
|
787
|
+
if (!WhipIngestProtocol.isValidStillRequestId(requestId)) return null
|
|
788
|
+
val ingest = media.ingestUrl ?: return null
|
|
789
|
+
if (!ingest.endsWith(WhipIngestProtocol.BASE_PATH)) return null
|
|
790
|
+
return ingest.removeSuffix(WhipIngestProtocol.BASE_PATH) + WhipIngestProtocol.STILL_PATH + "/" + requestId
|
|
791
|
+
}
|
|
792
|
+
|
|
776
793
|
/**
|
|
777
794
|
* Destroy the current SoftAP listener generation and bind a new one on the address the
|
|
778
795
|
* scoped network reports *now* — the caller rejoins first, then asks for this.
|
|
@@ -893,6 +910,34 @@ class AcsMeetingSession(
|
|
|
893
910
|
mediaRestartAttempts = 0
|
|
894
911
|
}
|
|
895
912
|
|
|
913
|
+
/** Remember a still that reached the Teams sender, so a later disconnect can be correlated with it. */
|
|
914
|
+
fun noteStillHeld(bytes: Int) {
|
|
915
|
+
lastStillHeldAtMs = SystemClock.elapsedRealtime()
|
|
916
|
+
lastStillBytes = bytes
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Ignore glasses frames and send a card or still instead. Reports through [onResult] once one
|
|
921
|
+
* replacement frame has been handed to the sender, so the caller can stop the camera after that.
|
|
922
|
+
*
|
|
923
|
+
* Asynchronous: the hold does its own off-thread wait for the first frame rather than blocking
|
|
924
|
+
* the caller, so the RN module thread never stalls (and risks an ANR) inside this call.
|
|
925
|
+
*/
|
|
926
|
+
fun setOutgoingHold(kind: String, imageBytes: ByteArray?, onResult: (Boolean) -> Unit) {
|
|
927
|
+
if (kind == "live") {
|
|
928
|
+
outgoingHold.stop()
|
|
929
|
+
onResult(true)
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
outgoingHold.start(kind, imageBytes, onResult)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private fun forwardGlassesPlanes(planes: I420Planes) {
|
|
936
|
+
if (outgoingHold.isActive()) return
|
|
937
|
+
DecodedFrameTap.offer(planes)
|
|
938
|
+
if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
|
|
939
|
+
}
|
|
940
|
+
|
|
896
941
|
fun setMuted(next: Boolean): Map<String, Any> {
|
|
897
942
|
if (MediaDiagnostics.videoArm == VideoSourceArm.SYNTHETIC) {
|
|
898
943
|
muted.set(true)
|
|
@@ -958,6 +1003,8 @@ class AcsMeetingSession(
|
|
|
958
1003
|
}
|
|
959
1004
|
|
|
960
1005
|
fun leave() {
|
|
1006
|
+
outgoingHold.stop()
|
|
1007
|
+
StillPhotoInbox.cancelAll()
|
|
961
1008
|
val submittedAt = SystemClock.elapsedRealtime()
|
|
962
1009
|
// Invalidate before queueing: leaveLocked cannot run until the executor finishes the current
|
|
963
1010
|
// join/hang-up, and without this bump a Cancel sits behind an unbounded ACS Future.
|
|
@@ -1235,10 +1282,36 @@ class AcsMeetingSession(
|
|
|
1235
1282
|
val end = describeEndReason(call)
|
|
1236
1283
|
// A failed connection is recoverable; do not tell the wearer the remote meeting ended.
|
|
1237
1284
|
// Wait for DISCONNECTED because DISCONNECTING may not yet carry the final reason.
|
|
1238
|
-
|
|
1285
|
+
val endCode = (end["code"] as? Number)?.toInt()
|
|
1286
|
+
if (state == CallState.DISCONNECTED && endCode != null && endCode != 0) {
|
|
1239
1287
|
phase = "error"
|
|
1240
1288
|
lastError = "ACS_CONNECTION_LOST: The Teams connection was lost. Check mobile data and rejoin. " +
|
|
1241
1289
|
"(code=${end["code"]}, subcode=${end["subcode"]})"
|
|
1290
|
+
val sinceStillMs = if (lastStillHeldAtMs == 0L) -1L else SystemClock.elapsedRealtime() - lastStillHeldAtMs
|
|
1291
|
+
val sinceConnectedMs = if (connectedAtMs == 0L) -1L else SystemClock.elapsedRealtime() - connectedAtMs
|
|
1292
|
+
// 430/10065 has arrived with the glasses stream still live. These fields say whether a still
|
|
1293
|
+
// was on the tile at that moment, so the next capture can separate a Teams network drop
|
|
1294
|
+
// from the photo path.
|
|
1295
|
+
Log.w(
|
|
1296
|
+
TAG,
|
|
1297
|
+
"ACS disconnect context code=$endCode subcode=${end["subcode"]} sinceConnectedMs=$sinceConnectedMs " +
|
|
1298
|
+
"sinceStillMs=$sinceStillMs stillBytes=$lastStillBytes holdActive=${outgoingHold.isActive()} " +
|
|
1299
|
+
"mediaSource=$mediaSource videoEnabled=$videoEnabled source=$currentSourceKind",
|
|
1300
|
+
)
|
|
1301
|
+
SoftApTrace.stage(
|
|
1302
|
+
"acs_disconnect_context",
|
|
1303
|
+
*diag(
|
|
1304
|
+
"endCode" to endCode,
|
|
1305
|
+
"endSubcode" to (end["subcode"] ?: -1),
|
|
1306
|
+
"sinceConnectedMs" to sinceConnectedMs,
|
|
1307
|
+
"sinceStillMs" to sinceStillMs,
|
|
1308
|
+
"stillBytes" to lastStillBytes,
|
|
1309
|
+
"holdActive" to outgoingHold.isActive(),
|
|
1310
|
+
"mediaSource" to mediaSource.name.lowercase(),
|
|
1311
|
+
"videoEnabled" to videoEnabled,
|
|
1312
|
+
"source" to currentSourceKind.name.lowercase(),
|
|
1313
|
+
),
|
|
1314
|
+
)
|
|
1242
1315
|
}
|
|
1243
1316
|
Log.i(TAG, "ACS call state=$state phase=$phase previous=$previous end=$end")
|
|
1244
1317
|
if (callId.isEmpty()) callId = readCallId()
|
|
@@ -1943,6 +2016,8 @@ class AcsMeetingSession(
|
|
|
1943
2016
|
keepAgent: Boolean = false,
|
|
1944
2017
|
failures: AtomicReference<Exception?>? = null,
|
|
1945
2018
|
) {
|
|
2019
|
+
outgoingHold.stop()
|
|
2020
|
+
StillPhotoInbox.cancelAll()
|
|
1946
2021
|
// Invalidate first: a bounded ACS operation still in flight has to find a stale generation
|
|
1947
2022
|
// rather than attach an agent to a session that is being torn down.
|
|
1948
2023
|
joinGeneration.incrementAndGet()
|
|
@@ -103,6 +103,18 @@ class AcsFrameSender(
|
|
|
103
103
|
|
|
104
104
|
fun isReady(): Boolean = stream.get() != null && running.get() && format.get() != null
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The size ACS currently expects, or null before the first negotiated format arrives.
|
|
108
|
+
*
|
|
109
|
+
* [sendPlanes] drops any frame whose dimensions differ from this, so a hold that renders to the
|
|
110
|
+
* profile ceiling instead of this size is dropped whenever ACS negotiates below it.
|
|
111
|
+
*/
|
|
112
|
+
fun negotiatedSize(): TargetSize? {
|
|
113
|
+
val fmt = format.get() ?: return null
|
|
114
|
+
if (fmt.width <= 0 || fmt.height <= 0) return null
|
|
115
|
+
return TargetSize(fmt.width, fmt.height)
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
/** True when the frame was submitted to ACS; false when a readiness, size, pacing or busy gate dropped it. */
|
|
107
119
|
fun sendPlanes(planes: I420Planes): Boolean {
|
|
108
120
|
val out = stream.get()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.video
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.Matrix
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.graphics.BitmapFactory
|
|
10
|
+
import android.media.ExifInterface
|
|
11
|
+
import com.mentra.glassesmedia.source.I420Planes
|
|
12
|
+
import java.io.ByteArrayInputStream
|
|
13
|
+
import java.nio.ByteBuffer
|
|
14
|
+
import java.util.concurrent.Executors
|
|
15
|
+
import java.util.concurrent.ScheduledFuture
|
|
16
|
+
import java.util.concurrent.TimeUnit
|
|
17
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Replaces glasses frames on the outgoing Teams tile.
|
|
21
|
+
*
|
|
22
|
+
* A gap, or a timestamp of zero, makes Teams hold a random freeze. This pump
|
|
23
|
+
* keeps handing the existing frame sender a full-frame card or still, and the
|
|
24
|
+
* sender's own clock keeps the timestamps moving.
|
|
25
|
+
*/
|
|
26
|
+
class OutgoingVideoHold(
|
|
27
|
+
private val send: (I420Planes) -> Boolean,
|
|
28
|
+
private val width: () -> Int,
|
|
29
|
+
private val height: () -> Int,
|
|
30
|
+
) {
|
|
31
|
+
private val running = AtomicBoolean(false)
|
|
32
|
+
private val scheduler = Executors.newSingleThreadScheduledExecutor { runnable ->
|
|
33
|
+
Thread(runnable, "acs-photo-hold").apply { isDaemon = true }
|
|
34
|
+
}
|
|
35
|
+
private var task: ScheduledFuture<*>? = null
|
|
36
|
+
private var planes: HeldPlanes? = null
|
|
37
|
+
|
|
38
|
+
fun isActive(): Boolean = running.get()
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Begin the hold and report through [onResult] once one card or still frame has been handed to
|
|
42
|
+
* the sender, or `false` if none lands within [HANDED_TIMEOUT_MS].
|
|
43
|
+
*
|
|
44
|
+
* Non-blocking: the wait runs on the hold scheduler, never on the caller's thread, so the RN
|
|
45
|
+
* module thread cannot stall (and risk an ANR) waiting for ACS to accept the first frame.
|
|
46
|
+
*/
|
|
47
|
+
fun start(kind: String, imageBytes: ByteArray?, onResult: (Boolean) -> Unit) {
|
|
48
|
+
stop()
|
|
49
|
+
val w = width()
|
|
50
|
+
val h = height()
|
|
51
|
+
val frame = when (kind) {
|
|
52
|
+
"image" -> imageBytes?.let { decode(it, w, h) } ?: card(w, h)
|
|
53
|
+
else -> card(w, h)
|
|
54
|
+
}
|
|
55
|
+
planes = frame
|
|
56
|
+
running.set(true)
|
|
57
|
+
val settled = AtomicBoolean(false)
|
|
58
|
+
task = scheduler.scheduleAtFixedRate({
|
|
59
|
+
val current = planes ?: return@scheduleAtFixedRate
|
|
60
|
+
if (!running.get()) return@scheduleAtFixedRate
|
|
61
|
+
if (send(current.frame()) && settled.compareAndSet(false, true)) onResult(true)
|
|
62
|
+
}, 0, 100, TimeUnit.MILLISECONDS)
|
|
63
|
+
// Fail the hold if no frame lands in the window, without blocking the caller.
|
|
64
|
+
scheduler.schedule({
|
|
65
|
+
if (settled.compareAndSet(false, true)) onResult(false)
|
|
66
|
+
}, HANDED_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun stop() {
|
|
70
|
+
running.set(false)
|
|
71
|
+
task?.cancel(false)
|
|
72
|
+
task = null
|
|
73
|
+
planes = null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun card(width: Int, height: Int): HeldPlanes {
|
|
77
|
+
val w = width.coerceAtLeast(16)
|
|
78
|
+
val h = height.coerceAtLeast(16)
|
|
79
|
+
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
80
|
+
val canvas = Canvas(bitmap)
|
|
81
|
+
canvas.drawColor(Color.rgb(18, 18, 22))
|
|
82
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
83
|
+
color = Color.WHITE
|
|
84
|
+
textAlign = Paint.Align.CENTER
|
|
85
|
+
textSize = (h / 12f).coerceAtLeast(24f)
|
|
86
|
+
}
|
|
87
|
+
canvas.drawText("Taking a photo", w / 2f, h / 2f, paint)
|
|
88
|
+
return HeldPlanes(bitmap)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decode the still and letterbox it onto a [width]x[height] frame. The frame must match the
|
|
93
|
+
* negotiated size the caller passed, or [send] drops it for a dimension mismatch.
|
|
94
|
+
*/
|
|
95
|
+
private fun decode(bytes: ByteArray, width: Int, height: Int): HeldPlanes? {
|
|
96
|
+
val w = width.coerceAtLeast(16)
|
|
97
|
+
val h = height.coerceAtLeast(16)
|
|
98
|
+
val decoded = decodeUpright(bytes, w, h) ?: return null
|
|
99
|
+
val canvasBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
100
|
+
val canvas = Canvas(canvasBitmap)
|
|
101
|
+
canvas.drawColor(Color.rgb(18, 18, 22))
|
|
102
|
+
val scale = minOf(w.toFloat() / decoded.width, h.toFloat() / decoded.height)
|
|
103
|
+
val drawWidth = decoded.width * scale
|
|
104
|
+
val drawHeight = decoded.height * scale
|
|
105
|
+
val dst = RectF(
|
|
106
|
+
(w - drawWidth) / 2f,
|
|
107
|
+
(h - drawHeight) / 2f,
|
|
108
|
+
(w + drawWidth) / 2f,
|
|
109
|
+
(h + drawHeight) / 2f,
|
|
110
|
+
)
|
|
111
|
+
canvas.drawBitmap(decoded, null, dst, Paint(Paint.FILTER_BITMAP_FLAG))
|
|
112
|
+
decoded.recycle()
|
|
113
|
+
return HeldPlanes(canvasBitmap)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Decode at no more than twice the frame size, with the EXIF orientation applied. Glasses JPEGs
|
|
118
|
+
* carry their rotation in EXIF rather than in the pixels, and a full 12 MP decode would allocate
|
|
119
|
+
* ~48 MB for a frame that is shown at 1280x720.
|
|
120
|
+
*/
|
|
121
|
+
private fun decodeUpright(bytes: ByteArray, frameWidth: Int, frameHeight: Int): Bitmap? {
|
|
122
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
123
|
+
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
124
|
+
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
|
125
|
+
val longEdge = maxOf(bounds.outWidth, bounds.outHeight)
|
|
126
|
+
val target = maxOf(frameWidth, frameHeight) * 2
|
|
127
|
+
var sample = 1
|
|
128
|
+
while (longEdge / (sample * 2) >= target) sample *= 2
|
|
129
|
+
val decoded = BitmapFactory.decodeByteArray(
|
|
130
|
+
bytes,
|
|
131
|
+
0,
|
|
132
|
+
bytes.size,
|
|
133
|
+
BitmapFactory.Options().apply { inSampleSize = sample },
|
|
134
|
+
) ?: return null
|
|
135
|
+
val orientation = runCatching {
|
|
136
|
+
ExifInterface(ByteArrayInputStream(bytes))
|
|
137
|
+
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
|
138
|
+
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
|
139
|
+
val matrix = Matrix()
|
|
140
|
+
when (orientation) {
|
|
141
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
|
142
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
|
143
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
144
|
+
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
|
145
|
+
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
146
|
+
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.postRotate(90f); matrix.postScale(-1f, 1f) }
|
|
147
|
+
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.postRotate(270f); matrix.postScale(-1f, 1f) }
|
|
148
|
+
else -> return decoded
|
|
149
|
+
}
|
|
150
|
+
val upright = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
|
151
|
+
if (upright !== decoded) decoded.recycle()
|
|
152
|
+
return upright
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
companion object {
|
|
156
|
+
private const val HANDED_TIMEOUT_MS = 2_000L
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private class HeldPlanes(bitmap: Bitmap) {
|
|
161
|
+
private val y: ByteBuffer
|
|
162
|
+
private val u: ByteBuffer
|
|
163
|
+
private val v: ByteBuffer
|
|
164
|
+
private val width = bitmap.width
|
|
165
|
+
private val height = bitmap.height
|
|
166
|
+
private val chromaWidth = (width + 1) / 2
|
|
167
|
+
private val chromaHeight = (height + 1) / 2
|
|
168
|
+
|
|
169
|
+
init {
|
|
170
|
+
val pixels = IntArray(width * height)
|
|
171
|
+
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
|
172
|
+
bitmap.recycle()
|
|
173
|
+
y = ByteBuffer.allocateDirect(width * height)
|
|
174
|
+
u = ByteBuffer.allocateDirect(chromaWidth * chromaHeight)
|
|
175
|
+
v = ByteBuffer.allocateDirect(chromaWidth * chromaHeight)
|
|
176
|
+
for (row in 0 until height) {
|
|
177
|
+
for (col in 0 until width) {
|
|
178
|
+
val color = pixels[row * width + col]
|
|
179
|
+
val r = (color shr 16) and 0xff
|
|
180
|
+
val g = (color shr 8) and 0xff
|
|
181
|
+
val b = color and 0xff
|
|
182
|
+
y.put(row * width + col, (((66 * r + 129 * g + 25 * b + 128) shr 8) + 16).toByte())
|
|
183
|
+
if (row % 2 == 0 && col % 2 == 0) {
|
|
184
|
+
val index = (row / 2) * chromaWidth + (col / 2)
|
|
185
|
+
u.put(index, (((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128).toByte())
|
|
186
|
+
v.put(index, (((112 * r - 94 * g - 18 * b + 128) shr 8) + 128).toByte())
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fun frame(): I420Planes {
|
|
193
|
+
y.position(0)
|
|
194
|
+
u.position(0)
|
|
195
|
+
v.position(0)
|
|
196
|
+
return I420Planes(y, width, u, chromaWidth, v, chromaWidth, width, height, System.nanoTime())
|
|
197
|
+
}
|
|
198
|
+
}
|
package/ios/AcsFrameSender.swift
CHANGED
|
@@ -53,6 +53,18 @@ final class AcsFrameSender: NSObject {
|
|
|
53
53
|
return true
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// The size ACS currently expects. A hold frame built at any other size is tagged with the
|
|
57
|
+
/// negotiated format and dropped for a dimension mismatch, so callers render to this.
|
|
58
|
+
func negotiatedSize() -> (width: Int, height: Int)? {
|
|
59
|
+
stateLock.lock()
|
|
60
|
+
defer { stateLock.unlock() }
|
|
61
|
+
guard let stream else { return nil }
|
|
62
|
+
let width = Int(stream.format.width)
|
|
63
|
+
let height = Int(stream.format.height)
|
|
64
|
+
guard width > 0, height > 0 else { return nil }
|
|
65
|
+
return (width, height)
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
func detach() {
|
|
57
69
|
stateLock.lock()
|
|
58
70
|
running = false
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
2
|
import AzureCommunicationCalling
|
|
3
3
|
import AzureCommunicationCommon
|
|
4
|
+
import CoreGraphics
|
|
5
|
+
import CoreText
|
|
4
6
|
import ExpoModulesCore
|
|
5
7
|
import Foundation
|
|
6
8
|
import GlassesMedia
|
|
9
|
+
import ImageIO
|
|
7
10
|
|
|
8
11
|
public class AcsMeetingModule: Module {
|
|
9
12
|
private var session: AcsMeetingSession?
|
|
@@ -173,6 +176,18 @@ public class AcsMeetingModule: Module {
|
|
|
173
176
|
session.setMuted(muted) { promise.resolve($0) }
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
AsyncFunction("holdOutgoingVideo") { (kind: String, imageBase64: String?, promise: Promise) in
|
|
180
|
+
guard let session = self.session else {
|
|
181
|
+
promise.reject(AcsMeetingError("No active meeting"))
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if session.setOutgoingHold(kind: kind, imageBase64: imageBase64) {
|
|
185
|
+
promise.resolve(nil)
|
|
186
|
+
} else {
|
|
187
|
+
promise.reject(AcsMeetingError("The outgoing card was not sent"))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
AsyncFunction("setVideoEnabled") { (enabled: Bool, promise: Promise) in
|
|
177
192
|
guard let session = self.session else {
|
|
178
193
|
promise.reject(AcsMeetingError("No active meeting"))
|
|
@@ -620,11 +635,12 @@ final class AcsMeetingSession {
|
|
|
620
635
|
self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
|
|
621
636
|
}
|
|
622
637
|
let source: DecodedGlassesMediaSource = sourceConfig.kind == .softap ? LocalWhipIngestSource() : WhepVideoSource()
|
|
623
|
-
source.onFrame = { [
|
|
624
|
-
|
|
625
|
-
//
|
|
638
|
+
source.onFrame = { [weak self] buffer in
|
|
639
|
+
guard let self else { return }
|
|
640
|
+
// A photo card or still owns the tile. Glasses frames stay off it until that hold ends.
|
|
641
|
+
if self.holdingOutgoing { return }
|
|
626
642
|
DecodedFrameTap.shared.offer(buffer)
|
|
627
|
-
if frameSender.send(buffer) { DecodedFrameTap.shared.recordAcsSend() }
|
|
643
|
+
if self.frameSender.send(buffer) { DecodedFrameTap.shared.recordAcsSend() }
|
|
628
644
|
}
|
|
629
645
|
source.onPcm = { [weak self] pcm, rate, channels in
|
|
630
646
|
self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
|
|
@@ -812,8 +828,48 @@ final class AcsMeetingSession {
|
|
|
812
828
|
}
|
|
813
829
|
}
|
|
814
830
|
|
|
831
|
+
private var holdingOutgoing = false
|
|
832
|
+
private var holdTimer: DispatchSourceTimer?
|
|
833
|
+
private var holdBuffer: CVPixelBuffer?
|
|
834
|
+
|
|
835
|
+
/// Drop glasses frames and keep sending a full-frame card so Teams does not freeze the last picture.
|
|
836
|
+
func setOutgoingHold(kind: String, imageBase64: String?) -> Bool {
|
|
837
|
+
if kind == "live" {
|
|
838
|
+
stopOutgoingHold()
|
|
839
|
+
return true
|
|
840
|
+
}
|
|
841
|
+
stopOutgoingHold()
|
|
842
|
+
// Render at the size ACS negotiated, not the profile ceiling: ACS renegotiates below the
|
|
843
|
+
// ceiling under load, and a hold frame built at 1280x720 would be dropped for a mismatch.
|
|
844
|
+
let size = frameSender.negotiatedSize() ?? (width: AcsOutgoingVideo.hd.width, height: AcsOutgoingVideo.hd.height)
|
|
845
|
+
guard let buffer = outgoingHoldPixelBuffer(kind: kind, imageBase64: imageBase64, width: size.width, height: size.height) else {
|
|
846
|
+
return false
|
|
847
|
+
}
|
|
848
|
+
holdBuffer = buffer
|
|
849
|
+
holdingOutgoing = true
|
|
850
|
+
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
851
|
+
timer.schedule(deadline: .now(), repeating: .milliseconds(100))
|
|
852
|
+
timer.setEventHandler { [weak self] in
|
|
853
|
+
guard let self, let buffer = self.holdBuffer, self.holdingOutgoing else { return }
|
|
854
|
+
_ = self.frameSender.send(buffer)
|
|
855
|
+
}
|
|
856
|
+
holdTimer = timer
|
|
857
|
+
timer.resume()
|
|
858
|
+
return frameSender.send(buffer)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private func stopOutgoingHold() {
|
|
862
|
+
holdingOutgoing = false
|
|
863
|
+
holdTimer?.cancel()
|
|
864
|
+
holdTimer = nil
|
|
865
|
+
holdBuffer = nil
|
|
866
|
+
}
|
|
867
|
+
|
|
815
868
|
func leave() {
|
|
816
|
-
queue.async {
|
|
869
|
+
queue.async {
|
|
870
|
+
self.stopOutgoingHold()
|
|
871
|
+
self.leaveLocked()
|
|
872
|
+
}
|
|
817
873
|
}
|
|
818
874
|
|
|
819
875
|
func leaveAndAwait(timeout: TimeInterval, completion: @escaping (Bool) -> Void) {
|
|
@@ -1023,6 +1079,7 @@ final class AcsMeetingSession {
|
|
|
1023
1079
|
|
|
1024
1080
|
private func leaveLocked(emitIdle: Bool = true) {
|
|
1025
1081
|
dispatchPrecondition(condition: .onQueue(queue))
|
|
1082
|
+
stopOutgoingHold()
|
|
1026
1083
|
audioDiagnostics?.finish()
|
|
1027
1084
|
audioDiagnostics = nil
|
|
1028
1085
|
joinGeneration &+= 1
|
|
@@ -1483,3 +1540,153 @@ private func joinMeeting(agent: CommonCallAgent, locator: TeamsMeetingLinkLocato
|
|
|
1483
1540
|
completion(nil, AcsMeetingError("Unsupported meeting agent"))
|
|
1484
1541
|
}
|
|
1485
1542
|
}
|
|
1543
|
+
|
|
1544
|
+
/// Card or still for the outgoing tile. A missing still falls back to the card, never the last live frame.
|
|
1545
|
+
func outgoingHoldPixelBuffer(kind: String, imageBase64: String?, width: Int, height: Int) -> CVPixelBuffer? {
|
|
1546
|
+
let w = max(16, width)
|
|
1547
|
+
let h = max(16, height)
|
|
1548
|
+
if kind == "image",
|
|
1549
|
+
let imageBase64,
|
|
1550
|
+
let data = Data(base64Encoded: imageBase64),
|
|
1551
|
+
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
|
1552
|
+
let image = CGImageSourceCreateImageAtIndex(source, 0, nil),
|
|
1553
|
+
let buffer = nv12PixelBuffer(image: image, width: w, height: h) {
|
|
1554
|
+
return buffer
|
|
1555
|
+
}
|
|
1556
|
+
return takingPhotoCardPixelBuffer(width: w, height: h) ?? takingPhotoPixelBuffer(width: w, height: h)
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
func takingPhotoCardPixelBuffer(width: Int, height: Int) -> CVPixelBuffer? {
|
|
1560
|
+
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
1561
|
+
guard let context = CGContext(
|
|
1562
|
+
data: nil,
|
|
1563
|
+
width: width,
|
|
1564
|
+
height: height,
|
|
1565
|
+
bitsPerComponent: 8,
|
|
1566
|
+
bytesPerRow: width * 4,
|
|
1567
|
+
space: colorSpace,
|
|
1568
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
1569
|
+
) else { return nil }
|
|
1570
|
+
context.setFillColor(red: 18 / 255, green: 18 / 255, blue: 22 / 255, alpha: 1)
|
|
1571
|
+
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
1572
|
+
let font = CTFontCreateWithName("Helvetica" as CFString, CGFloat(max(24, height / 12)), nil)
|
|
1573
|
+
let attrs = [
|
|
1574
|
+
kCTFontAttributeName: font,
|
|
1575
|
+
kCTForegroundColorAttributeName: CGColor(gray: 1, alpha: 1),
|
|
1576
|
+
] as CFDictionary
|
|
1577
|
+
guard let attributed = CFAttributedStringCreate(nil, "Taking a photo" as CFString, attrs) else { return nil }
|
|
1578
|
+
let line = CTLineCreateWithAttributedString(attributed)
|
|
1579
|
+
var ascent: CGFloat = 0
|
|
1580
|
+
let lineWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, nil, nil))
|
|
1581
|
+
context.textPosition = CGPoint(x: (CGFloat(width) - lineWidth) / 2, y: (CGFloat(height) - ascent) / 2)
|
|
1582
|
+
CTLineDraw(line, context)
|
|
1583
|
+
guard let image = context.makeImage() else { return nil }
|
|
1584
|
+
return nv12PixelBuffer(image: image, width: width, height: height)
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
func nv12PixelBuffer(image: CGImage, width: Int, height: Int) -> CVPixelBuffer? {
|
|
1588
|
+
var rgba = [UInt8](repeating: 0, count: width * height * 4)
|
|
1589
|
+
let drew = rgba.withUnsafeMutableBytes { raw -> Bool in
|
|
1590
|
+
guard let base = raw.baseAddress else { return false }
|
|
1591
|
+
guard let context = CGContext(
|
|
1592
|
+
data: base,
|
|
1593
|
+
width: width,
|
|
1594
|
+
height: height,
|
|
1595
|
+
bitsPerComponent: 8,
|
|
1596
|
+
bytesPerRow: width * 4,
|
|
1597
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
1598
|
+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
1599
|
+
) else { return false }
|
|
1600
|
+
context.setFillColor(red: 18 / 255, green: 18 / 255, blue: 22 / 255, alpha: 1)
|
|
1601
|
+
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
1602
|
+
let scale = min(CGFloat(width) / CGFloat(image.width), CGFloat(height) / CGFloat(image.height))
|
|
1603
|
+
let drawWidth = CGFloat(image.width) * scale
|
|
1604
|
+
let drawHeight = CGFloat(image.height) * scale
|
|
1605
|
+
context.interpolationQuality = .high
|
|
1606
|
+
context.draw(
|
|
1607
|
+
image,
|
|
1608
|
+
in: CGRect(
|
|
1609
|
+
x: (CGFloat(width) - drawWidth) / 2,
|
|
1610
|
+
y: (CGFloat(height) - drawHeight) / 2,
|
|
1611
|
+
width: drawWidth,
|
|
1612
|
+
height: drawHeight
|
|
1613
|
+
)
|
|
1614
|
+
)
|
|
1615
|
+
return true
|
|
1616
|
+
}
|
|
1617
|
+
guard drew else { return nil }
|
|
1618
|
+
let attrs = [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary
|
|
1619
|
+
var buffer: CVPixelBuffer?
|
|
1620
|
+
let status = CVPixelBufferCreate(
|
|
1621
|
+
kCFAllocatorDefault,
|
|
1622
|
+
width,
|
|
1623
|
+
height,
|
|
1624
|
+
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
|
1625
|
+
attrs,
|
|
1626
|
+
&buffer
|
|
1627
|
+
)
|
|
1628
|
+
guard status == kCVReturnSuccess, let buffer else { return nil }
|
|
1629
|
+
CVPixelBufferLockBaseAddress(buffer, [])
|
|
1630
|
+
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
|
|
1631
|
+
guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(buffer, 0),
|
|
1632
|
+
let uvPlane = CVPixelBufferGetBaseAddressOfPlane(buffer, 1) else { return nil }
|
|
1633
|
+
let yRow = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
|
|
1634
|
+
let uvRow = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
|
|
1635
|
+
let y = yPlane.assumingMemoryBound(to: UInt8.self)
|
|
1636
|
+
let uv = uvPlane.assumingMemoryBound(to: UInt8.self)
|
|
1637
|
+
for row in 0..<height {
|
|
1638
|
+
for col in 0..<width {
|
|
1639
|
+
let pixel = (row * width + col) * 4
|
|
1640
|
+
let alpha = Int(rgba[pixel + 3])
|
|
1641
|
+
var r = Int(rgba[pixel])
|
|
1642
|
+
var g = Int(rgba[pixel + 1])
|
|
1643
|
+
var b = Int(rgba[pixel + 2])
|
|
1644
|
+
if alpha != 0 && alpha != 255 {
|
|
1645
|
+
r = min(255, r * 255 / alpha)
|
|
1646
|
+
g = min(255, g * 255 / alpha)
|
|
1647
|
+
b = min(255, b * 255 / alpha)
|
|
1648
|
+
}
|
|
1649
|
+
y[row * yRow + col] = UInt8(clamping: ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16)
|
|
1650
|
+
if row % 2 == 0 && col % 2 == 0 {
|
|
1651
|
+
let index = (row / 2) * uvRow + col
|
|
1652
|
+
uv[index] = UInt8(clamping: ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128)
|
|
1653
|
+
uv[index + 1] = UInt8(clamping: ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128)
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
return buffer
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/// Full-frame card sent while the glasses camera is busy with a photo. Dark, not the last live picture.
|
|
1661
|
+
func takingPhotoPixelBuffer(width: Int, height: Int) -> CVPixelBuffer? {
|
|
1662
|
+
let attrs = [kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary
|
|
1663
|
+
var buffer: CVPixelBuffer?
|
|
1664
|
+
let status = CVPixelBufferCreate(
|
|
1665
|
+
kCFAllocatorDefault,
|
|
1666
|
+
width,
|
|
1667
|
+
height,
|
|
1668
|
+
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
|
1669
|
+
attrs,
|
|
1670
|
+
&buffer
|
|
1671
|
+
)
|
|
1672
|
+
guard status == kCVReturnSuccess, let buffer else { return nil }
|
|
1673
|
+
CVPixelBufferLockBaseAddress(buffer, [])
|
|
1674
|
+
defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
|
|
1675
|
+
if let yPlane = CVPixelBufferGetBaseAddressOfPlane(buffer, 0) {
|
|
1676
|
+
let rowBytes = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
|
|
1677
|
+
let yHeight = CVPixelBufferGetHeightOfPlane(buffer, 0)
|
|
1678
|
+
memset(yPlane, 18, rowBytes * yHeight)
|
|
1679
|
+
let band = yHeight / 5
|
|
1680
|
+
let bandY = (yHeight - band) / 2
|
|
1681
|
+
let y = yPlane.assumingMemoryBound(to: UInt8.self)
|
|
1682
|
+
for row in bandY..<(bandY + band) {
|
|
1683
|
+
memset(y.advanced(by: row * rowBytes), 235, rowBytes)
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if let uvPlane = CVPixelBufferGetBaseAddressOfPlane(buffer, 1) {
|
|
1687
|
+
let rowBytes = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
|
|
1688
|
+
let uvHeight = CVPixelBufferGetHeightOfPlane(buffer, 1)
|
|
1689
|
+
memset(uvPlane, 128, rowBytes * uvHeight)
|
|
1690
|
+
}
|
|
1691
|
+
return buffer
|
|
1692
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/acs-meeting",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
3
|
+
"version": "3.3.0-dev.489",
|
|
4
4
|
"description": "MentraOS native ACS Teams meeting module (WHEP decode → ACS raw media)",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@expo/config-plugins": "~55.0.10",
|
|
35
|
-
"@mentra/glasses-media": "3.3.0-dev.
|
|
35
|
+
"@mentra/glasses-media": "3.3.0-dev.489"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"expo-module-scripts": "^55.0.2",
|