@mentra/acs-meeting 3.3.0-dev.487 → 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 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. Production never generates pixels on
38
- the phone, and no video frame ever crosses the JavaScript bridge.
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
- if (state == CallState.DISCONNECTED && (end["code"] as? Number)?.toInt()?.let { it != 0 } == true) {
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
+ }
@@ -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 = { [frameSender] buffer in
624
- // Preview first, deliberately: the tap must see frames the ACS sender's pacing and
625
- // readiness gates would otherwise hide, and it cannot delay or break this call.
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 { self.leaveLocked() }
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.487",
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.487"
35
+ "@mentra/glasses-media": "3.3.0-dev.489"
36
36
  },
37
37
  "devDependencies": {
38
38
  "expo-module-scripts": "^55.0.2",